Paying homage to the Compall-Michael pattern, and anticipating Scala 3 Opaque Types
Many Scala developers are excited by the arrival of Scala 3 in 2021. Despite the risks that any language evolution brings, in the case of the evolution from Scala 2 to Scala 3, these are more than compensated for by the impressive range of new language features.
As for me, I already have my favourite new language feature — Opaque Types. Scala for me has always been about the ability to express rich models of some domain in a type-safe way. Opaque types are another great addition to the toolbox.
But the introduction of this new feature also means that it is time for me to say goodbye to one of my favourite Scala 2 techniques, something I call the "Compall-Michael" pattern — and I should point out that I am the only person on the planet that uses this term so there is no use googling it!
"Compall" refers to Stephen Compall who outlined this technique in his talk at LamdaConf 2018 entitled "Opaque Types to Infinity" — see the talk here and download the slides here. I am a big fan of Stephen Compall’s writings so this is my own way of paying homage. "Michael" refers to Julien Michael who is cited in the talk as the original inspiration.
What is the problem that we are trying to solve?
In a nutshell, we want to have "compile-time wrapper types" that provide type-safety at compile-time but without runtime overhead (e.g. no extra memory allocations). This is more easily illustrated with an example (even a toy one), which I will borrow from the Opaque Types section of the Moving Forward from Scala 2 to Scala 3 course that a team of us at Lunatech, led by Eric Loots, prepared and released in 2020.
I will start by illustrating the example using well-known features of Scala 2 - Plain type aliases, Case classes, and Value classes - before moving on to show the "Compall-Michael" representation, and then finally finishing with a solution using Scala 3 Opaque Types.
(Note that I do not cover all the ways that we can solve this in Scala — e.g. using tagged types or even other approaches that make use of excellent libraries such as scala-newtype. Such approaches using third-party libraries are well worth considering in your own real-world use-cases.)
The example talks about Rockets, Boosters, Kilometers, and Miles and was inspired by the famous Mars Probe story of faulty conversion between metric and imperial units. (I feel the need to stress that if you are building an application and you need to work with dimensions and units of measure in a type-safe way then you would almost certainly want to use a proven library like Squants. The snippets below simply constitute toy examples to illustrate and compare different approaches.)
Plain (Transparent) Type Aliases
A typical Scala 2 approach is to use plain type aliases for readability. In the following code snippet, instead of using Double
, we introduce two aliases Kilometers
and Miles
which help us to document when we are working in kilometres and when we are working in miles — e.g. we can document that the Booster
class works with miles as a unit of measure.
object Units {
type Kilometers = Double
type Miles = Double
}
import Units._
class Booster() {
def provideLaunchBoost(): Miles = 100
}
However, this does not provide any type-safety because the aliased type is transparent. Consider the following code snippet where we use the other defined alias Kilometers
to help us to document that the Rocket
class works with kilometres as a unit. The fact that Miles
is transparently a Double
means that we can inadvertently add kilometres and miles together and the code will compile just fine - meaning that a bug has just slipped through.
class Rocket(booster: Booster) {
private var distance: Kilometers = 0
def launch(): Unit = {
// Kilometers and Miles are transparent. They are both Double so this bug slips through
distance += booster.provideLaunchBoost()
}
def distanceTravelled: Kilometers = distance
}
val rocket: Rocket = new Rocket(new Booster())
rocket.launch();
// Will think it has travelled 100km rather than 160km
rocket.distanceTravelled
Similarly, as these are just values of type Double
, with just these plain type aliases we are unable to enforce certain constraints such as "distance cannot be negative". Ideally we want to make this kind of illegal state unrepresentable.
Case class wrappers
The simplest approach to achieve type-safety and catch the bug of mixing up miles and kilometres at compile-time would be to create distinct new "wrapper" types. In the following code snippet, we introduce two case classes Kilometers
and Miles
that each "wrap" their real underlying Double
value. And because these are distinct types we are not able to just simply operate on miles when kilometres is the expected unit of measure. The case classes provide a way to create values of Kilometers
and Miles
and we provide two helper functions Units.add
and Units.toKilometers
for adding two values of Kilometers
and for converting from Miles
to Kilometers
.
object Units {
final case class Kilometers(value: Double)
final case class Miles(value: Double)
def add(km1: Kilometers, km2: Kilometers): Kilometers = Kilometers(km1.value + km2.value)
def toKilometers(miles: Miles): Kilometers = Kilometers(miles.value * 1.6)
}
import Units._
class Booster() {
def provideLaunchBoost(): Miles = Miles(100)
}
class Rocket(booster: Booster) {
private var distance: Kilometers = Kilometers(0)
def launch(): Unit = {
// Kilometers and Miles are different types. So compiler prevents the previous bug
val launchBoost: Kilometers = toKilometers(booster.provideLaunchBoost())
distance = add(distance, launchBoost)
}
def distanceTravelled: Kilometers = distance
}
val rocket: Rocket = new Rocket(new Booster())
rocket.launch();
// Will represent the correct distance travelled
rocket.distanceTravelled
Furthermore, if we wanted to enforce the constraints about distance only being positive, we could take the "smart constructor" approach to restrict how we obtain values of type Kilometers
and Miles
. There are quite a number of ways to achieve this in Scala. For a good explanation of the various ways to implement smart constructors I invite you to check out this recent blog post. As I am using Scala 2.13.5 to compile these examples, I will take advantage of the newer cleaner approach of using a private[foo]
access modifier that is possible with the -Xsource:3
compiler option. (If you are on a version of Scala pre-2.13.2, then I would personally recommend the approach of using sealed abstract case class
, which is also explained in the linked blog post).
In the following code snippet, the private[Units]
modifier means we can only access the Kilometers()
and Miles()
constructors from within the scope of object Units
— externally the only way to obtain Kilometers
and Miles
values is through the kilometers
and miles
"smart constructors" that perform some validation.
object Units {
final case class Kilometers private[Units] (value: Double)
final case class Miles private[Units] (value: Double)
val ZeroKm: Kilometers = Kilometers(0)
val ZeroMi: Miles = Miles(0)
def kilometers(value: Double): Option[Kilometers] = if (value < 0) None else Some(Kilometers(value))
def miles(value: Double): Option[Miles] = if (value < 0) None else Some(Miles(value))
def add(km1: Kilometers, km2: Kilometers): Kilometers = Kilometers(km1.value + km2.value)
def toKilometers(miles: Miles): Kilometers = Kilometers(miles.value * 1.6)
}
import Units._
class Booster() {
def provideLaunchBoost(): Miles = miles(100).getOrElse(ZeroMi)
}
class Rocket(booster: Booster) {
private var distance: Kilometers = ZeroKm
def launch(): Unit = {
// Kilometers and Miles are different types. So compiler prevents the previous bug
val launchBoost: Kilometers = toKilometers(booster.provideLaunchBoost())
distance = add(distance, launchBoost)
}
def distanceTravelled: Kilometers = distance
}
val rocket: Rocket = new Rocket(new Booster())
rocket.launch();
// Will represent the correct distance travelled
rocket.distanceTravelled
So we have achieved what we wanted — which is that we prevent the bug at compile-time — but at the cost of some runtime overhead because we now have to allocate the wrapper Kilometers
and Miles
objects in memory. It is safe to bet that in practice for most applications this extra overhead is not critical and personally I reach for this simple approach most of the times. However, it is not unheard of to have some performance-critical use-cases where you want to avoid the overhead of unnecessarily allocating and garbage-collecting objects.
Value-class wrappers
Extending the wrappers with AnyVal
promises to eliminate the overhead of using a the case-class wrapper presented in the previous section. Classes that extend AnyVal
, in addition to meeting a number of other criteria as explained here on this Scala Lang page, are known as value classes. The compiler can still prevent the bug of incorrectly mixing up values of Miles
and Kilometers
, but without the need to allocate wrapper objects.
object Units {
final case class Kilometers private[Units] (value: Double) extends AnyVal
final case class Miles private[Units] (value: Double) extends AnyVal
...
}
// Same as before
...
However, in practice allocations can still happen in a number of situations. For example, imagine that we decide to allow the toKilometers
method to accept values of a super-type Distance
(of which Kilometers
and Miles
would be two sub-types), we could have code like the following.
object Units {
sealed trait Distance extends Any
final case class Kilometers private[Units] (value: Double) extends AnyVal with Distance
final case class Miles private[Units] (value: Double) extends AnyVal with Distance
...
def toKilometers(distance: Distance): Kilometers = distance match {
case miles: Miles => Kilometers(miles.value * 1.6)
case kilometers: Kilometers => kilometers
}
}
This code is perfectly fine, but if you had chosen the AnyVal
route to avoid allocations, then you would be disappointed to discover that now with the supertype-subtype relationship you will once again have allocations when you pass a value of Kilometers
or Miles
to the function toKilometers
.
import Units._
...
class Rocket(booster: Booster) {
private var distance: Kilometers = ZeroKm
def launch(): Unit = {
val launchBoost: Kilometers = toKilometers(booster.provideLaunchBoost()) // Allocation of Miles object
distance = add(distance, launchBoost)
}
def distanceTravelled: Kilometers = distance
}
The various limitations of Value-classes are extensively discussed on the Scala Lang page on Value Classes and on the SIP-15 page there is recognition that improvements can be made for certain use-cases (e.g. numerical computing).
As a quick aside, one interesting limitation is that value classes cannot be nested. Although not strictly necessary for our particular use-case, imagine that we wanted to add some convenient syntax for invoking our helper functions add
and toKilometers
— e.g. using +
infix operator instead of add
and being able to invoke .toKm
on a Miles
value instead of toKilometers
. One typical usage of Value-classes is in combination with implicit classes to provide allocation-free extension methods. However, if we attempt to define extension methods in this way, as in the following code snippet, then the code does not compile:
object Units {
final case class Kilometers private[Units] (value: Double) extends AnyVal
final case class Miles private[Units] (value: Double) extends AnyVal
...
implicit class KmOps(val km: Kilometers) extends AnyVal {
def +(km2: Kilometers): Kilometers = add(km, km2)
}
implicit class MiOps(val miles: Miles) extends AnyVal {
def toKm: Kilometers = toKilometers(miles)
}
}
This gives the following compilation error:
implicit class KmOps(val km: Kilometers) extends AnyVal {
^
error: value class may not wrap another user-defined value class
So in this case, to get around the limitation of nested value-classes, we have to define the extension methods using just implicit class
without the extends AnyVal
(accepting that this will allocate new instances of these implicit classes when using the extension methods):
object Units {
final case class Kilometers private[Units] (value: Double) extends AnyVal
final case class Miles private[Units] (value: Double) extends AnyVal
...
implicit class KmOps(val km: Kilometers) {
def +(km2: Kilometers): Kilometers = add(km, km2)
}
implicit class MiOps(val miles: Miles) {
def toKm: Kilometers = toKilometers(miles)
}
}
import Units._
...
class Rocket(booster: Booster) {
...
def launch(): Unit = {
distance += booster.provideLaunchBoost().toKm
}
...
}
What it looks like with the "Compall-Michael" pattern
In contrast to the preceding well-known and widely-used techniques seen thus far in the post, the approach that I am calling here the "Compall-Michael" pattern is almost certainly less well known (even though in some respects it already has a very long heritage in Scala). This technique makes use of Scala’s ability to emulate the ML module system, which I first heard Martin Odersky discuss in his flatMap Oslo talk in 2014 (click here for the slides) and which is very well explained in this blog post from that same year entitled "Scala’s Modular Roots" by Dan James.
For an in-depth explanation of how the emulation of ML modules in Scala can be achieved, I invite you to read the blog post linked above (and if you want a crash course on ML modules then this blog post is a good quick read). Here I will just say that the main aspects that interest us are emulating ML signatures using sealed trait
s and emulating ML structures by implementing the signature with new { … }
and assigning it to a val
. (In our use case we will not need to emulate ML functors which is the third important aspect of the ML module system).
The signature can be thought of as the public API of our module without any implementation. This is an API that not only consists of abstract functions (def
declarations) but also unassigned values (val
declarations) and abstract types (type
declarations). In the code snippet below we have our signature UnitsModule
that we define as a sealed trait
(it could equally by a sealed abstract class
). Inside the signature, we reproduce the same public API that we introduced already in the "Case class wrappers" section. Specifically, we have:
- abstract type members Kilometers
and Miles
- abstract val
declarations ZeroKm
and ZeroMi
that will represent "zero" values of our two types of unit
- abstract def
smart constructor declarations kilometers
and miles
that will allow us to create instances of our two types of unit
- abstract def
declarations that allow us to do useful things with our two types of unit, in this case a method add
for adding two Kilometers
values and a method toKilometers
for converting a Miles
value to a Kilometers
value
- and finally, if we want to, we can even define our extension methods from before, defined in terms of the add
and toKilometers
methods. (Note, however, that once again we cannot make use of "allocation-free extension methods" because of the limitation of only being able to define them at the top-level or enclosed in a statically accessible object.)
sealed trait UnitsModule {
type Kilometers
type Miles
val ZeroKm: Kilometers
val ZeroMi: Miles
def kilometers(value: Double): Option[Kilometers]
def miles(value: Double): Option[Miles]
def add(km1: Kilometers, km2: Kilometers): Kilometers
def toKilometers(miles: Miles): Kilometers
implicit class KmOps(val km: Kilometers) {
def +(km1: Kilometers): Kilometers = add(km, km1)
}
implicit class MiOps(val miles: Miles) {
def toKm: Kilometers = toKilometers(miles)
}
}
The structure can be thought of as the implementation of the signature. In the following code snippet, we instantiate an anonymous class that implements the UnitsModule
trait and assign the instance to val Units
. In the body of the anonymous class we provide concrete definitions of the two types Kilometers
and Miles
, defining them both as Double
. Similarly, we provide implementations of all the val
and def
declarations, and in these implementations we can treat values of type Kilometers
and Miles
as Double
values (meaning e.g. that we can directly perform arithmetic operations such as +
and *
on these values.
val Units: UnitsModule = new UnitsModule {
type Kilometers = Double
type Miles = Double
val ZeroKm: Kilometers = 0
val ZeroMi: Miles = 0
def kilometers(value: Double): Option[Kilometers] = if (value < 0) None else Some(value)
def miles(value: Double): Option[Miles] = if (value < 0) None else Some(value)
def add(km1: Kilometers, km2: Kilometers): Kilometers = km1 + km2
def toKilometers(miles: Miles): Kilometers = miles * 1.6
}
However, even though on the inside of the body of new UnitsModule { … }
we know that both Kilometers
and Miles
are represented as Double
values, from the outside the types Units.Kilometers
and Units.Miles
are completely different and cannot be used interchangeably. So the compiler can again prevent the bug of adding a value of type Miles
to a value of type Kilometers
. But in this case, there is absolutely no wrapping involved and the runtime representation of Miles
and Kilometers
values is indeed Double
.
import Units._
class Booster() {
def provideLaunchBoost(): Miles = miles(100).getOrElse(ZeroMi)
}
class Rocket(booster: Booster) {
private var distance: Kilometers = ZeroKm
def launch(): Unit = {
// Kilometers and Miles are different types. So compiler prevents the previous bug
distance += booster.provideLaunchBoost().toKm
}
def distanceTravelled: Kilometers = distance
}
val rocket: Rocket = new Rocket(new Booster())
rocket.launch();
// Will represent the correct distance travelled
rocket.distanceTravelled
The one big gotcha of this approach is that the ascription val Units: UnitModule
is crucial. If you omit that then you break the abstraction and allow the outside world to see that Kilometers
and Miles
are just aliases for Double
and you are back to square one. Having the type annotation :UnitModule
means that the outside world can only treat Kilometers
and Miles
as existential types (meaning, in a nutshell, that the outside world knows only that types of these two names exist, but nothing else). It is this subtle move that gives us the needed opacity around the real representation of Kilometers
and Miles
.
So consider the following code snippet where we have forgotten the annotation and we have simply val Units = new UnitsModule
(or we could have the same effect with object Units extends UnitsModule
), now were are back to the situation where Kilometers
and Miles
are transparently Double
and we are back to being able to make the original mistake of adding miles to kilometres.
val Units = new UnitsModule {
type Kilometers = Double
type Miles = Double
val ZeroKm: Kilometers = 0
val ZeroMi: Miles = 0
def kilometers(value: Double): Option[Kilometers] = if (value < 0) None else Some(value)
def miles(value: Double): Option[Miles] = if (value < 0) None else Some(value)
def add(km1: Kilometers, km2: Kilometers): Kilometers = km1 + km2
def toKilometers(miles: Miles): Kilometers = miles * 1.6
}
import Units._
class Booster() {
def provideLaunchBoost(): Miles = miles(100).getOrElse(ZeroMi)
}
class Rocket(booster: Booster) {
private var distance: Kilometers = ZeroKm
def launch(): Unit = {
// Kilometers and Miles are once again transparent so back to initial bug
distance += booster.provideLaunchBoost()
}
def distanceTravelled: Kilometers = distance
}
val rocket: Rocket = new Rocket(new Booster())
rocket.launch();
// Will think it has travelled 100km rather than 160km
rocket.distanceTravelled
I highly recommend that you watch the talk and/or read the slides as it goes much further than the basic usage that I have described here. Particularly fascinating is the example on infinitely recursive types around minute 40 of the talk. I should also point out that the main motivation put forward in Stephen Compall’s talk is "improving abstraction" rather than any concerns about memory allocations or performance-critical use-cases.
What it looks like with Scala 3 Opaque Type Aliases
Opaque types were originally proposed in SIP-35 (cf. the Motivation section). According to the Dotty docs, they aim to "provide type abstraction without any overhead”. Scala 3 introduces the opaque
keyword that can be added in front of a plain type alias.
object Units {
opaque type Kilometers = Double
opaque type Miles = Double
}
However, these type aliases by themselves are not very useful. That is because, outside of the scope of Units
we only know the type names Kilometers
and Miles
but we cannot do anything useful. At a minimum we need to provide a way to introduce values of our opaque types and a public API for working with values of our opaque types. So here again we reproduce the public API that we first introduced in the "Case class wrappers" section, as well as the extension methods that provide convenient syntax for our Units.add
and Units.toKilometers
helper functions. However, for this we make use of the new Extension methods feature of Scala 3. The combination of Opaque Types and Extension methods go well together in Scala 3 and make for a cleaner final solution.
object Units {
opaque type Kilometers = Double
opaque type Miles = Double
val ZeroKm: Kilometers = 0
val ZeroMi: Miles = 0
def kilometers(value: Double): Option[Kilometers] = if (value < 0) None else Some(value)
def miles(value: Double): Option[Miles] = if (value < 0) None else Some(value)
def add(km1: Kilometers, km2: Kilometers): Kilometers = km1 + km2
def toKilometers(miles: Miles): Kilometers = miles * 1.6
extension (km: Kilometers) {
def + (km2: Kilometers): Kilometers = add(km, km2)
}
extension (miles: Miles) {
def toKm: Kilometers = toKilometers(miles)
}
}
And now, even though inside the body of Units
we can treat both Kilometers
and Miles
as Double
values, on the outside, we cannot use these types interchangeably and we have to do the necessary conversion. Note that the extension methods make it a cleaner to add two Kilometers
values and to convert from Miles
to Kilometers
.
import Units._
class Booster() {
def provideLaunchBoost(): Miles = miles(100).getOrElse(ZeroMi)
}
class Rocket(booster: Booster) {
private var distance: Kilometers = ZeroKm
def launch(): Unit = {
// Kilometers and Miles are different types. So compiler prevents the previous bug
distance += booster.provideLaunchBoost().toKm
}
def distanceTravelled: Kilometers = distance
}
// For fun, let's make use of Scala 3's Universal Apply Methods to omit the 'new'
val rocket: Rocket = Rocket(Booster())
rocket.launch();
// Will represent the correct distance travelled
rocket.distanceTravelled
Wrap up
Hopefully this blog post has made you as keen as I am to really get going with Scala 3 to make use of the new expressive capabilities, Opaque Types being just one of many. If you want to start exploring this very promising new evolution of Scala then the best place to start is the official Scala Lang Scala 3 page. If you want self-paced, hands-on practical exercises to get acquainted with some of these new expressive capabilities, I invite you to run through Lunatech’s Moving Forward from Scala 2 to Scala 3 course. However, if you are still likely to be using Scala 2 for the near to medium term, I hope this post was a gentle introduction to the interesting "Compall-Michael" technique for declaring "compile-time wrapper types" that makes use of existing language features.