The phantom of the type opera
HEIKO Seeberger over at the Codecentric blog published an interesting post about using Scala's typelevel programming to encode phantom types in a strict way so that you could tightly control the types that are allowed to be phantasmal.
For example, if you have a
and you call
hacker.hackOn, you want a compile-time error
saying essentially that a decaffeinated hacker can't code on.
Heiko's techniques make some tradeoffs:
- Having to encode the methods' phantom type requirements as type bound sandwiches or implicit evidence of types. This is required if we want to keep using object-oriented method call syntax.
- Having to bound the
Hacker's phantom type parameter to an explicit hierarchy of allowed phantom types, i.e. either
Stateor its subtypes
Decaffeinated. I believe this is unnecessary, as the
Hackerconstructor is private and the companion object provides smart constructors that allow you to get only a
If we trade away the object-oriented syntax, and give up the unnecessary phantom type hierarchy, we get:
In this version, a few things are going on. By line number:
- We make the phantom type parameter unbound and
invariant, because we made the constructor private. Client code can't
Hackers, it can only use the ones we provide. Also, we move all the logic out of the class body and into the companion object.
- 4, 5
- We don't need a phantom type hierarchy, or even to
seal the traits, because again client code can only use the
Hackers that we provide, and the phantom type parameter is, as mentioned, invariant. Also, I don't put the states inside their own companion object because they're merely incidental to the main logic; they don't have any logic dedicated to them.
- 7, 8
- We provide the smart constructors here. Note that the constructors don't need to be methods; they can just be values because these values are immutable. So operations on them can just keep reusing the same values.
- 10, 15
- We make the
drinkCoffeemethods both take and return a
Hackerwith the correct phantom type to explicitly show the transitions
Hacker[Caffeinated] => Hacker[Decaffeinated]and
Hacker[Decaffeinated] => Hacker[Caffeinated].
- 12, 18
- We use our own smart constructors internally to separate interface from implementation as much as possible.
With the above code, we can get the highly desirable 'type mismatch' error that immediately tells us what's wrong:
scala> Hacker drinkCoffee Hacker.caffeinated <console>:8: error: type mismatch; found : Hacker[Hacker.Caffeinated] required: Hacker[Hacker.Decaffeinated] Hacker drinkCoffee Hacker.caffeinated ^ scala> Hacker hackOn (Hacker hackOn (Hacker drinkCoffee Hacker.decaffeinated)) <console>:8: error: type mismatch; found : Hacker[Hacker.Decaffeinated] required: Hacker[Hacker.Caffeinated] Hacker hackOn (Hacker hackOn (Hacker drinkCoffee Hacker.decaffeinated)) ^
Admittedly, we've given up object-oriented syntax to get here. But I personally think the tradeoff is worth it.