Sealed traits are essential for idiomatic Scala code, but many developers are hazy on the details of their working. In this post we describe why you want to use them, and how to use them correctly to increase the quality of your code.
Algebraic Data Types
We can explain sealed in terms of its low-level semantics, but I find it much more useful to start with the big picture. The most important use of sealed is in defining algebraic data types. Despite their fancy name, algebraic data types are just a way of modelling data in terms of two patterns:
- logical ors, such as
List
, which is a::
orNil
; and - logical ands, such as
::
, which has ahead
and atail
.
In functional programming lingo we call the logical or a sum type and the logical and a product type.
Algebraic data types are really really important. The standard library is chock full of them (Option
, List
, and Try
are some examples) and you can probably think of places you can use them in your own work. A common example is modelling different types of users. You might have a structure like an Account
that can be an Admin
or a normal User
. An Account
will have certain properties like an emailAddress
, a username
, and so on. Admins
probably have properties unique to them such as an accessLevel
. This complex definition is just an algebraic data type — it is defined entirely in terms of ands and ors.
Using some simple patterns we can mechanically translate the description of an algebraic data type into code. I won’t go into the detailed Scala implementation of the sum and product type patterns here, but let’s see a quick example for List
described above1:
sealed trait List[+A] {
// lotsa methods in here ...
}
final case class ::[A](head: A, tl: List[A]) extends List[A]
final case class Nil extends List[Nothing]
Notice the use of sealed
(and final
).
Structural Recursion
So, algebraic data types are really useful and we define them using sealed
traits, but what does sealing a trait actually get us? It’s time to look at how we write code that uses algebraic data types. This also has a fancy name, structural recursion, but the basic idea is simple.
Let’s start with a very familiar example, Option
. Option
is a Some
or None
(a sum type), and Some
has an element x
(a product type, albeit a very simple one containing a single field). How do we write a match
expression for Option
? We need:
- one case for
Some
and one forNone
; and - the case for
Some
should do something with the elementx
.
In code:
anOption match {
case None => doNoneCase
case Some(x) => doSomeCase(x)
}
We can generalise this pattern to any algebraic data type. Each branch in a sum type (a logical or) gets its own case
in the pattern matching, and each product type (a logical and) requires us to extract and do something with the elements. This is all that structural recursion is.
In the same way that we can mechanically convert the description of an algebraic data type into code, we can mechanically convert an algebraic data type into a skeleton for using that type.
Now we’ve seen three neat things: we can model a very general class of data using algebraic data types, we can mechanically convert an algebraic data type into Scala definitions, and we can mechanically write Scala code to use any algebraic data type via structural recursion. But we still haven’t said what sealed
gets us. Let me seal the deal and finally explain their importance.
Exhaustiveness Checking
When we define an algebraic data type using sealed
traits we allow the compiler to perform exhaustiveness checking. In simpler words, this means the compiler will shout at us if we miss out a case in our structural recursion.
Here’s an example at the Scala console:
scala> Option(1) match {
| case None => "Yeah"
| }
<console>:8: warning: match may not be exhaustive.
It would fail on the following input: Some(_)
Option(1) match {
^
Exhaustiveness checking is extremely useful. You may have used Java APIs that sometimes return null
, or perhaps Ruby which uses nil
where in Scala we’d use an Option
. If so you’ll know how easy it is to forget to check for the null
(or nil
) case. Exhaustiveness checking completely prevents this type of error. It is also extremely useful when refactoring code. For example, if we extend an algebraic data type (say we add a new type of User
) the compiler will tell us every place in our code base that needs to be updated.
Exhaustiveness checking allows us to make stronger guarantees about the correctness of our code, but only if we write our code in a way that lets the compiler do these checks for us. This is where sealed
comes in.
The Inner Life of Sealed
A sealed
trait can only be extended within the file in which it defined. This allows the compiler to perform exhaustiveness checking for pattern matches on that trait. Why? Because the compiler must know all the possible subtypes to do the checks, and given that the JVM allows code to be loaded at runtime the compiler can’t scan the whole program to collect all the possible subtypes (and even if it could it would prevent separate compilation and be rather slow).
A small example illustrates this.
First we declare a sealed trait and some subtypes:
sealed trait Base
final case class SubtypeOne(a: Int) extends Base
final case class SubtypeTwo(b: Option[String]) extends Base
Now we can see that the compiler provides exhaustiveness checking. We declare an instance with type Base
and we get a warning on an incomplete pattern match as we expect:
scala> (SubtypeOne(1) : Base) match {
| case SubtypeOne(a) => a + 2
| }
<console>:11: warning: match may not be exhaustive.
It would fail on the following input: SubtypeTwo(_)
(SubtypeOne(1) : Base) match {
^
(It’s a good idea to turn warnings into errors with the -Xfatal-warnings
compiler flag.)
Now on to the point that a lot of Scala programmers are hazy on: sealed
is not transitive. Meaning that although Base
above is sealed
, this does not mean that SubtypeOne
and SubtypeTwo
are sealed and hence we do not get exhaustiveness checking when we match on a value with type SubtypeOne
or SubtypeTwo
. For example we get no warning that we’re missing the None
case here, instead getting a MatchError
exception at runtime.
scala> SubtypeTwo(Some("oops")) match {
| case SubtypeTwo(None) => "Yeah!"
| }
scala.MatchError: SubtypeTwo(Some(oops)) (of class SubtypeTwo)
Let’s be clear on what’s going on here:
- we are matching on a value with type
SubtypeTwo
not typeBase
; SubtypeTwo
is notsealed
;- therefore the compiler cannot guarantee it knows everything about
SubtypeTwo
(there could be subtypes defined in another file); and - thus we do not get exhaustiveness checking.
You might argue that the compiler should give us a warning about the None
case here, since Option
is sealed
, but doing so would give unpredictable behaviour in general – we would sometimes get the checking and sometimes not depending on how exactly we defined our types and matches. It’s much better to have predictable semantics than to build a more complicated system that leads to surprises (and unexpected runtime crashes)!
Note that we can get exhaustive checking in the example above if we declare the type as Base
. Exhaustiveness checking is controlled entirely by the type of the expression being matched.
scala> (SubtypeTwo(Some("oops")) : Base) match {
| case SubtypeTwo(None) => "Yeah!"
| }
<console>:11: warning: match may not be exhaustive.
It would fail on the following inputs: SubtypeOne(_), SubtypeTwo(Some(_))
(SubtypeTwo(Some("oops")) : Base) match {
^
Final Words
The final
modifier has similar semantics to sealed
. A sealed trait can only be extended in the defining file, while a final class cannot be extended anywhere. In what seems to me an odd quirk, final classes do not get exhaustiveness checking.
Despite this I make case classes in algebraic data types final
, as in the examples above, and as in the standard library (see Some for example). There are few reasons for this. Firstly, I find it is more descriptive. It also opens up more optimisation possibilities than sealed
(though I don’t know if these opportunities are taken advantage of). Finally, when declaring types we almost always use the base type (e.g. Option
) instead of a subtype (e.g. None
) so the lack of exhaustiveness checking is very rarely an issue.
If you look at the standard library you’ll see sealed abstract classes are often used where I’ve used sealed traits in the examples here. I believe sealed abstract classes lead to a slightly faster implementation and easier Java interoperation. In my own practice I like to minimise the number of concepts I use, and as traits are generally more useful than abstract classes I prefer them.
Update If you made it this far you should check out the followup post.
-
In addition to illustrating sum and product types, this example also contains covariance. I decided it was better to use a more realistic example in this blog post, rather than an abstract definition showing just sum and product types. ↩