We’re all used to pattern matching across traits (typically sealed) but Shapeless gives us a more flexible approach. Shapeless Coproducts are not necessarily always the best option though. To find out why and when you should use this new pattern read on …
What’s wrong with sealed traits?
You are no doubt familiar with sealed traits and pattern matching on them:
sealed trait Error
case class BadName(name: String) extends Error
case class BadAge(age: Int) extends Error
...
registrationError match {
case BadName(name) => s"$name is bad"
case BadAge(age) => "$age is bad"
}
What’s wrong with this pattern? Well there’s nothing wrong with it but there are a few shortcomings:
If we forget to handle of of the errors we will get the dreaded “MatchError” at runtime. As the trait is sealed the
compiler will warn us about this and if we enable the -Xfatal-warnings
compiler flag we can even trigger a compiler
error. This flag applies globally though. Sometimes (ok it’s very rare) we actually want to ignore a match because we
know it will never happen at runtime.
There’s also a nasty compiler bug, SI-5365 which means the warnings or compile errors will be ignored if we use a guard somewhere in the pattern match. So this will compile without errors or warnings:
registrationError match {
case BadName(name) if name.isEmpty => s"$name is bad"
}
It will still blow up at runtime though if we get a BadAge
error!
Finally, look at the match statement. In both cases we return a String, we need to do this to avoid widening the type
to Any. So we basically have [A <: Error] => String
. Most of the time this is fine but sometimes we want to return a
different type depending on the match.
Shapeless Coproducts
Shapless Coproducts are just a specialised form of HLists. If you don’t know about HLists think of them as Tuples on Steroids. Before we get started you’ll need to pull Shapeless in using the usual SBT syntax:
libraryDependencies += Seq(
"com.chuusai" %% "shapeless" % "2.3.3"
)
Replacing our sealed trait with a Coproduct
First we define our type:
import shapeless.{:+:, CNil, Coproduct}
...
case class BadName(name: String)
case class BadAge(age: Int)
type Error = BadName :+: BadAge :+: CNil
Notice how the definition looks like a regular list, except for the :+:
operator and the CNil
termination. We need no type hierarchy to link BadName
and BadAge
. These error types could even come from
different libraries.
Creating an instance of our Error
is a little different. There are a couple of ways of doing this but the simplest is
to use the Coproduct
helper:
val nameError = Coproduct[Error](BadName("John"))
Pattern matching on the Coproduct
Again there are a few ways of doing this. Our error is an Hlist
which is like a tuple on steroids. Therefore the
simplest is to map or fold the error using something called a polymorphic function
Polymorphic functions
I’ll write more about polymorphic functions in another post but they are basically that can handle more than one type at runtime. My last point is important, we can of course define a method with a type parameter:
def handleError[A](error: A): String = input.toString
However when we create a first class function from this we need to fix the type at compile time:
val handleErrorFn = handleError[BadName] _
error.map(handleErrorFn)
This won’t work for us because we want a function that can match across BadName
and BadAge
. Let’s create a
polymorphic function that can handle both scenarios:
import shapeless.{:+:, CNil, Coproduct, Poly1}
...
object errorHandler extends Poly1 {
implicit def name = at[BadName] { e => s"bad first name: ${e.name}" }
implicit def age = at[BadAge] { e => s"bad age: ${e.age}" }
}
Poly1
simply means a function that accepts one parameter. We then define handlers for each scenario. The code won’t
compile if we forget to handle one of the scenarios. We get the type safety of sealed trait with more flexibility and
no SI-5635
issues. Finally we put it all together by folding the error using the handler:
val nameError = Coproduct[Error](BadName("John"))
val errorMessage = nameError.fold(errorHandler)
println(errorMessage)
Why fold?
Our error type is BadName :+: BadAge :+: CNil
(an HList) but we want a single scalar
value i.e. BadName :+: BadAge :+: CNil => String
so we fold the list to get a String
Returning a different type depending on the match
Let’s say that for some reason we want to keep the same underlying types when handling the error,
i.e. BadName => String
and BadAge => Int
. We can’t easily do this using a normal pattern match:
val matched: Any = myError match {
case BadName(name) => "BAD NAME"
case BadAge(age) => -1
}
The type of matches
must be Any
as there’s no other type to unify the String
and Int
However we can keep the different typs using Shapeless coproducts. To do so we map over the error instead of folding:
object errorHandler extends Poly1 {
implicit def name = at[BadName] { e => "BAD NAME" }
implicit def age = at[BadAge] { e => -1 }
}
...
val nameError = Coproduct[Error](BadName("John"))
val errorMessage = nameError.map(errorHandler)
However as we mapped instead of folding we get an Hlist back. Our transformation looks like
this BadName :+: BadAge :+: CNil => String :+: Int :+: CNil
To get our String
or Int
we can either fold as before and transform to a single type, or we can select
the type we want:
val maybeStringError: Option[String] = errorMessage.select[String]
val maybeIntError: Option[Int] = errorMessage.select[Int]