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 {
2
3 def hackOn: Hacker = {
4 println("Hacking, hacking, hacking!")
5 new Hacker
6 }
7
8 def drinkCoffee: Hacker = {
9 println("Slurp ...")
10 new Hacker
11 }
12}
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 3 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 {
2
3 sealed trait State
4 object State {
5 sealed trait Caffeinated extends State
6 sealed trait Decaffeinated extends State
7 }
8}
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._
3
4 def hackOn: Hacker[State.Decaffeinated] = {
5 println("Hacking, hacking, hacking!")
6 new Hacker
7 }
8
9 def drinkCoffee: Hacker[State.Caffeinated] = {
10 println("Slurp ...")
11 new Hacker
12 }
13}
Unfortunately we’re not yet there:
1scala> val hacker = new Hacker[Hacker.State.Caffeinated] 2hacker: Hacker[Hacker.State.Caffeinated] = Hacker@33e5ccce 3 4scala> hacker.hackOn 5Hacking, hacking, hacking! 6res0: Hacker[Hacker.State.Decaffeinated] = Hacker@34340fab 7 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 {
2
3 sealed trait State
4 object State {
5 sealed trait Caffeinated extends State
6 sealed trait Decaffeinated extends State
7 }
8
9 def caffeinated: Hacker[State.Caffeinated] = new Hacker
10 def decaffeinated: Hacker[State.Decaffeinated] = new Hacker
11}
12
13class Hacker[S <: Hacker.State] private {
14 import Hacker._
15
16 def hackOn[T >: S <: State.Caffeinated]: Hacker[State.Decaffeinated] = {
17 println("Hacking, hacking, hacking!")
18 new Hacker
19 }
20
21 def drinkCoffee[T >: S <: State.Decaffeinated]: Hacker[State.Caffeinated] = {
22 println("Slurp ...")
23 new Hacker
24 }
25}
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
7
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
4}
5
6def drinkCoffee(implicit ev: S =:= State.Decaffeinated): Hacker[State.Caffeinated] = {
7 println("Slurp ...")
8 new Hacker
9}
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]
3}
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 7 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
Conclusion
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.
More articles
fromHeiko Seeberger
Your job at codecentric?
Jobs
Agile Developer und Consultant (w/d/m)
Alle Standorte
More articles in this subject area
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.
Blog author
Heiko Seeberger
Do you still have questions? Just send me a message.
Do you still have questions? Just send me a message.