By: Matt Barackman
A collection of objects or case classes that share a sealed trait.
In the example below, the type family would be a collection of traffic light colors with Red
, Yellow
, and Green
as member objects.
These are part of one family as they all extend the sealed trait Color
.
// traffic_light/Colors.scala
sealed trait Color
object Red extends Color
object Yellow extends Color
object Green extends Color
A trait
in this case acts as a shared interface between objects or case classes that extend it.
They allow you to use the trait as a shared super-type in a method or variable signature. In the example below the trait Color
is the type signature of the parameter color
in the Car#react method. Any of the members of the
Color
type family (e.g. Red
, Green
or Yellow
) would be valid inputs.
//traffic_light/Car.scala
object Car {
def react(color: Color) = color match {
case Red => "stop"
case Yellow => "slow"
case Green => "continue"
}
}
sealed
means that you can only extend case classes or objects with this trait in the file in which the trait is defined.
Let's try and extend an object Blue
in a file other than the one the sealed trait Color
is defined in.
// traffic_light/NewColor.scala
import Color
object Blue extends Color
As you can't extend a sealed trait outside of the file it was defined in, this will result in a compiler error.
> sbt compile
[error] NewColor.scala:4: illegal inheritance from sealed trait Color
[error] object Blue extends Color
This guarantee provides a few advantages:
- It makes it easier to find and digest all the valid inputs to a method typed against a type family.
- It ensures that code in other files won't be creating unexpected inputs to methods typed against a type family.
- It allows the compiler to exhaustively check in a pattern match for all possible members of a type family.
Let's explore this third point further.
##What is Exhaustive Pattern Matching?
Let's revisit what we have so far.
We have a type family of traffic light colors with three members.
// traffic_light/Colors.scala
sealed trait Color
object Red extends Color
object Yellow extends Color
object Green extends Color
We have an object Car
with a react
method that is pattern matching against the Color
sealed trait.
// traffic_light/Car.scala
object Car {
def react(color: Color) = color match {
case Red => "stop"
case Yellow => "slow"
case Green => "continue"
}
}
As you can see when we jump into the console, calling this method with the various colors returns a string indicating the reaction a car (it's a self-driving one) would have upon receiving the various inputs.
> sbt console
scala> import traffic_light.{Car,Red}
scala> Car.react(Red)
res0: String = stop
But let's say that we accidentally forgot to tell the car how to react in case of a Red
traffic light.
// traffic_light/Car.scala
object Car {
def react(color: Color) = color match {
// case Red => "stop"
case Yellow => "slow"
case Green => "continue"
}
}
If we're using a sealed trait, we would see a compiler warning telling us that match may not be exhaustive
.
> sbt compile
[error] Car.scala:4: match may not be exhaustive.
[error] It would fail on the following input: Red
[error] def react(color: Color) = color match {
Why is this?
By using a sealed trait, all the definitions for the members of a type family guaranteed to be in one file. This makes it easy for the compiler to definitely know all of the members that are known to exist. Therefore, it can give us a helpful error message at compile time when we forget to account for one or more members in a pattern match.
But what happens if we don't use a sealed trait?
// traffic_light/Colors.scala
trait Color // sealed removed
object Red extends Color
object Yellow extends Color
object Green extends Color
Let's, again, leave the Car
code with the Red
condition commented out.
// traffic_light/Car.scala
object Car {
def react(color: Color) = color match {
// case Red => "stop"
case Yellow => "slow"
case Green => "continue"
}
}
And now, it will actually compile, but when we hop into console we get a runtime error when we pass Red
into the react
method.
> sbt console
scala> import traffic_light.{Car, Red}
scala> Car.react(Red)
scala.MatchError: $TrafficLight$Red$@4bc2b213 (of class $TrafficLight$Red$)
at $Car$.react(SimpleTypeFamilies.scala:4)
Why is this different? The compiler does not have all the type family member definitions guaranteed to be in one place, so it won't presume it knows every member that could exist. So it won't even check for an exhaustive match at compile time, and therefore compiles fine. But it can and will blow up at runtime.
So, the key takeaway here is that using sealed traits can turn runtime errors into compiler errors which are way less harmful as they can be more easily caught before code is deployed into production.
This compile time error behavior is also helpful if you want to extend the type family.
One might want to extend your Color
family to include more colors. Perhaps you'd want to add a BlinkingRed
option.
// traffic_light/Colors.scala
sealed trait Color
object BlinkingRed extends Color // new member
object Red extends Color
object Yellow extends Color
object Green extends Color
If you used a sealed trait and forgot to add the associated BlinkingRed
case statement to the Car.react
method, you would see a similar match may not be exhaustive
error at compile time.
> sbt compile
[error] Car.scala:4: match may not be exhaustive.
[error] It would fail on the following input: BlinkingRed
[error] def react(color: Color) = color match {
So, using sealed traits can help ensure that as you extend your type family you are accounting for new members everywhere that you may be pattern matching against the type family.
When you find yourself with a collection of similar objects or case classes that you want to treat as one type in some scenarios, but as individual members in others (particularly when you will be pattern matching) lean towards using sealed traits and type families.
It will:
- Keep you from having to source dive
- Limit the surface area of code that could create unexpected members as inputs
- And most importantly, it will create safer code by converting runtime errors into compiler errors when pattern matching against a type family
##Additional Reading:
Great post. Clear and to the point.