Coproducts and polymorphic functions for safety
I was recently exploring shapeless and a coworker turned me onto the interesting features of coproducts and how they can be used with polymorphic functions.
Frequently when using pattern matching you want to make sure that all cases are exhaustively checked. A non exhaustive pattern match is a runtime exception waiting to happen. As a scala user, I’m all about compile time checking. For classes that I own I can enforce exhaustiveness by creating a sealed trait heirarchy:
sealed trait Base
case class Sub1() extends Base
case class Sub2() extends Base
And if I ever try and match on an Base
type I’ll get a compiler warning (that I can fail on) if all the types aren’t matched. This is nice because if I ever add another type, I’ll get a (hopefully) failed build.
But what about the scenario where you don’t own the types?
case class Type1()
case class Type2()
case class Type3()
They’re all completely unrelated. Even worse is how do you create a generic function that accepts an instance of those 3 types but no others? You could always create overloaded methods:
def takesType(type: Type1) = ???
def takesType(type: Type2) = ???
def takesType1(type: Type3) = ???
Which works just fine, but what if that type needs to be passed through a few layers of function calls before its actually acted on?
def doStuff(type: Type1) = ... takesType(type1)
def doStuff(type: Type2) = ... takesType(type2)
def doStuff(type: Type3) = ... takesType(type3)
Oh boy, this is a mess. We can’t get around with just using generics with type bounds since there is no unified type for these 3 types. And even worse is if we add another type. We could use an either like Either[Type1, Either[Type2, Either[Type3, Nothing]]]
Which lets us write just one function and then we have to match on the subsets. This is kind of gross too since its polluted with a bunch of eithers. Turns out though, that a coproduct is exactly this… a souped up either!
Defining
type Items = Type1 :+: Type2 :+: Type3 :+: CNil
(where CNil is the terminator for a coproduct) we now have a unified type for our collection. We can write functions like :
def doStuff(item: Items) = {
// whatever
takesType(item)
}
At some point, you need to lift an instance of Type1
etc into a type of Item
and this can be done by calling Coproduct[Item](instance)
. This call will fail to compile if the type of the instance is not a type of Item
. You also are probably going to want to actually do work with the thing, so you need to unbox this souped up either and do stuff with it
This is where the shapeless PolyN
methods come into play.
object Worker {
type Items = Type1 :+: Type2 :+: Type3 :+: CNil
object thisIsAMethod extends Poly1 {
// corresponding def for the data type of the coproduct instance
implicit def invokedOnType1 = at[Type1](data =\> data.toString)
implicit def invokedOnType2 = at[Type2](data =\> data.toString)
implicit def invokedOnType3 = at[Type3](data =\> data.toString)
}
def takesItem(item: Item): String = {
thisIsAMethod(item)
}
}
class Provider {
Worker.takesItem(Coproduct[Item](Type1()) // ok
Worker.takesItem(Coproduct[Item](WrongType()) // fails
}
The object thisIsAMethod
creates a bunch of implicit type dependent functions that are defined at all the elements in the coproduct. If we add another option to our coproduct list, we’ll get a compiler error when we try and use the coproduct against the polymorphic function. This accomplishes the same thing as giving us the exhaustiveness check but its an even stronger guarantee as the build will fail.
While it is a lot of hoops to jump through, and can be a little mind bending, I’ve found that coproducts and polymorphic functions are a really nice addition to my scala toolbox. Being able to strongly enforce these kinds of contracts is pretty neat!