Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Star 16 You must be signed in to star a gist
  • Fork 3 You must be signed in to fork a gist
  • Save mattbarackman/82b1712add45ceffa8ffe56f81b8bcc2 to your computer and use it in GitHub Desktop.
Save mattbarackman/82b1712add45ceffa8ffe56f81b8bcc2 to your computer and use it in GitHub Desktop.
A brief lesson on Type Families, Sealed Traits, and Exhaustive Pattern Matching in Scala

Scala: Type Families, Sealed Traits, and Exhaustive Pattern Matching

By: Matt Barackman

What is a Type Family?

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

What is a Sealed Trait?

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:

  1. It makes it easier to find and digest all the valid inputs to a method typed against a type family.
  2. It ensures that code in other files won't be creating unexpected inputs to methods typed against a type family.
  3. 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.

Extending your Type Family

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.

Final Thoughts

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:

@amanbangad
Copy link

Thanks for this!

@TropComplique
Copy link

Thanks for the lesson.

@supersmashyang
Copy link

Thanks, very helpful

@mdyzhama
Copy link

Great post. Clear and to the point.

@bsao
Copy link

bsao commented Jun 4, 2019

Great post.

@gvolpe
Copy link

gvolpe commented Aug 15, 2019

If you came here searching for actual type families note that Color is not. It is a simple ADT (Algebraic Data Type).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment