Toby Hobson

Shapeless Coproducts

banner.png
Shapeless Coproducts
Estimated reading time: 5 minutes

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]
comments powered by Disqus

Need help with your project?

Do you need some help or guidance with your project? Reach out to me (email is best)