Phantom Types in Scala

5.2.2016 | 5 minutes of reading time

Inspired by a recent conversation with my former colleague Brendan McAdams and my current coworker Markus Hauck , I decided to put together a quick post about phantom types, a topic perfectly suited for demonstrating the power of the type system of Scala.

Phantom types are called this way, because they never get instantiated. Really? So what are they good for? Simply to encode type constraints, i.e. prevent some code from being compiled in certain situations.

Let’s look at an example: Software developers are known for their vast need of coffee. So let’s define the class Hacker that defines the two methods hackOn and drinkCoffee.

1class Hacker {
3  def hackOn: Hacker = {
4    println("Hacking, hacking, hacking!")
5    new Hacker
6  }
8  def drinkCoffee: Hacker = {
9    println("Slurp ...")
10    new Hacker
11  }

In the spirit of functional programming and immutable objects, both methods return a new instance of Hacker.

Of course the current implementation doesn’t encode the following constraint which is imposed by the very nature of human beings in general and software developer in particular: After hacking a Hacker can’t continue doing so, but needs to drink some coffee. Put another way, we should not be able to call hackOn again on the Hacker returned by a previous call of hackOn:

1scala> val hacker = new Hacker
2hacker: Hacker = Hacker@3fb4f649
4scala> hacker.hackOn.hackOn // This should not compile, but it does!
5Hacking, hacking, hacking!
6Hacking, hacking, hacking!
7res0: Hacker = Hacker@2ff5659e

We could solve this problem by introducing two subclasses of Hacker – e.g. ExhaustedHacker and CaffeinatedHacker – which only define the respective methods returning the other subclass. But sometimes this is not a viable option, e.g. if we need subclassing to implement some other concern. Just think of a class hierarchy of animals or such: In this case the former approach would lead to an explosion of classes.

Therefore let’s implement the two states a hacker can be in using type parameters. First we define the possible states in the companion object:

1object Hacker {
3  sealed trait State
4  object State {
5    sealed trait Caffeinated extends State
6    sealed trait Decaffeinated extends State
7  }

Here we use sealed to prevent the possible states from being extended. Notice that we don’t define any concrete subclasses, hence nobody will ever be able to instantiate State, State.Caffeinated or State.Decaffeinated.

Next we add a type parameter to Hacker and constrain it to a subtype of Hacker.State using an upper bound:

1class Hacker[S <: Hacker.State] {
2  import Hacker._
4  def hackOn: Hacker[State.Decaffeinated] = {
5    println("Hacking, hacking, hacking!")
6    new Hacker
7  }
9  def drinkCoffee: Hacker[State.Caffeinated] = {
10    println("Slurp ...")
11    new Hacker
12  }

Unfortunately we’re not yet there:

1scala> val hacker = new Hacker[Hacker.State.Caffeinated]
2hacker: Hacker[Hacker.State.Caffeinated] = Hacker@33e5ccce
4scala> hacker.hackOn
5Hacking, hacking, hacking!
6res0: Hacker[Hacker.State.Decaffeinated] = Hacker@34340fab
8scala> hacker.hackOn.hackOn  // This should not compile, but it still does!
9Hacking, hacking, hacking!
10Hacking, hacking, hacking!
11res1: Hacker[Hacker.State.Decaffeinated] = Hacker@3b6eb2ec

While calling hackOn returns a decaffeinated hacker, we’re still able to call hackOn a second time. This is not a surprise, because we haven’t constrained calling any of the two methods yet.

Type Bounds

Let’s fix that with a neat trick: In order to only allow calling hackOn on an instance of Hacker[State.Caffeinated], we have to make the compiler fail, if the Hacker is parameterized with any other State. Therefore we parameterize hackOn and constrain the parameter with an upper and a lower bound:

1def hackOn[T >: S <: State.Caffeinated]: Hacker[State.Decaffeinated]

When the compiler tries to infer a type for T, it has to find one that is a supertype of S which represents the hacker’s state and at the same time a subtype of State.Caffeinated. Obviously this cannot work if S is State.Decaffeinated, because neither State.Decaffeinated nor any of its supertypes is a subtype of State.Caffeinated.

So essentially by sandwiching the type parameter T of the method in between the parameter S of the class and the desired state we constrain calling the method to such instances which are parameterized with the desired state. Let’s complete the code for Hacker, thereby making the constructor private and providing factory methods for Hackers in each of the two states:

1object Hacker {
3  sealed trait State
4  object State {
5    sealed trait Caffeinated extends State
6    sealed trait Decaffeinated extends State
7  }
9  def caffeinated: Hacker[State.Caffeinated] = new Hacker
10  def decaffeinated: Hacker[State.Decaffeinated] = new Hacker
13class Hacker[S <: Hacker.State] private {
14  import Hacker._
16  def hackOn[T >: S <: State.Caffeinated]: Hacker[State.Decaffeinated] = {
17    println("Hacking, hacking, hacking!")
18    new Hacker
19  }
21  def drinkCoffee[T >: S <: State.Decaffeinated]: Hacker[State.Caffeinated] = {
22    println("Slurp ...")
23    new Hacker
24  }

Now we can only call hackOn on a Hacker[State.Caffeinated], i.e. after calling drinkCoffee and vice versa:

1scala> Hacker.caffeinated.hackOn.drinkCoffee.hackOn.drinkCoffee
2Hacking, hacking, hacking!
3Slurp ...
4Hacking, hacking, hacking!
5Slurp ...
6res0: Hacker[Hacker.State.Caffeinated] = Hacker@6b3b6b21
8scala> Hacker.caffeinated.hackOn.hackOn
9<console>:14: error: type arguments [Hacker.State.Decaffeinated] do not conform to method hackOn's type parameter bounds [T >: Hacker.State.Decaffeinated <: Hacker.State.Caffeinated]

Unfortunately the error messages we get from this sandwiching trick are not immensely helpful: type arguments [Hacker.State.Decaffeinated] do not conform to method hackOn’s type parameter bounds [T >: Hacker.State.Decaffeinated <: Hacker.State.Caffeinated]<="" em=""/>

Implicit Evidence

Therefore we can use another approach: We replace the respective type bounds with an implicit parameter of type S =:= State.Caffeinated or S =:= State.Decaffeinated:

1def hackOn(implicit ev: S =:= State.Caffeinated): Hacker[State.Decaffeinated] = {
2  println("Hacking, hacking, hacking!")
3  new Hacker
6def drinkCoffee(implicit ev: S =:= State.Decaffeinated): Hacker[State.Caffeinated] = {
7  println("Slurp ...")
8  new Hacker

This makes the compiler look for an implicit value of type =:= parameterized with the proper types. Predef defines an implicit method which provides such instances if the left and right hand sides are of the exact same type:

1object =:= {
2   implicit def tpEquals[A]: A =:= A = singleton_=:=.asInstanceOf[A =:= A]

Therefore we get the same effect like before, i.e. we can only call hackOn if Hacker is parameterized with State.Caffeinated and drinkCoffee if Hacker is parameterized with State.Decaffeinated:

1scala> Hacker.caffeinated.hackOn.drinkCoffee.hackOn.drinkCoffee
2Hacking, hacking, hacking!
3Slurp ...
4Hacking, hacking, hacking!
5Slurp ...
6res0: Hacker[Hacker.State.Caffeinated] = Hacker@3461511d
8scala> Hacker.caffeinated.hackOn.hackOn
9<console>:14: error: Cannot prove that Hacker.State.Decaffeinated =:= Hacker.State.Caffeinated.

As you can see, we get a much better error message than before: Cannot prove that Hacker.State.Decaffeinated =:= Hacker.State.Caffeinated


We have shown that phantom types can be used to encode type constraints. Sometimes these can be implemented in a different way, e.g. via subclassing, but when that’s not possible, they are a powerful tool. Some examples for real world use cases – not saying that the affinity of software developers for coffee isn’t real – are state machines and builders.

If you’re interested in the full source code, check out the project on GitHub . Any questions or feedback are highly appreciated.

share post




More articles in this subject area\n

Discover exciting further topics and let the codecentric world inspire you.


Gemeinsam bessere Projekte umsetzen.

Wir helfen Deinem Unternehmen.

Du stehst vor einer großen IT-Herausforderung? Wir sorgen für eine maßgeschneiderte Unterstützung. Informiere dich jetzt.

Hilf uns, noch besser zu werden.

Wir sind immer auf der Suche nach neuen Talenten. Auch für dich ist die passende Stelle dabei.