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 hacker:
Hacker[Decaffeinated]
, 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. eitherState
or its subtypesCaffeinated
,Decaffeinated
. I believe this is unnecessary, as theHacker
constructor is private and the companion object provides smart constructors that allow you to get only aHacker[State.Caffeinated]
or aHacker[State.Decaffeinated]
.
If we trade away the object-oriented syntax, and give up the unnecessary phantom type hierarchy, we get:
class Hacker[S] private () /* 1 */
object Hacker {
trait Decaffeinated /* 4 */
trait Caffeinated /* 5 */
val caffeinated: Hacker[Caffeinated] = new Hacker /* 7 */
val decaffeinated: Hacker[Decaffeinated] =
new Hacker /* 8 */
def hackOn( /* 10 */
hacker: Hacker[Caffeinated]): Hacker[Decaffeinated] = {
println("Hacking, hacking, hacking!")
decaffeinated /* 12 */
}
def drinkCoffee( /* 15 */
hacker: Hacker[Decaffeinated]): Hacker[Caffeinated] = {
println("Slurp...")
caffeinated /* 18 */
}
}
In this version, a few things are going on. By line number:
- 1
- We make the phantom type parameter unbound and
invariant, because we made the constructor private. Client code can't
create any
Hacker
s, 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
Hacker
s 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
hackOn
anddrinkCoffee
methods both take and return aHacker
with the correct phantom type to explicitly show the transitionsHacker[Caffeinated] => Hacker[Decaffeinated]
andHacker[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.