Scala 3's Type System, Part 2: Enums and ADTs
Most domain bugs I have chased were not logic errors. They were a record that
said isPaid = false, isShipped = true, or a status field containing the string
"shpped". Scala 3βs enum makes that whole class of record impossible to
construct, and it takes about as many lines as the version that lets you.
Checked against Scala 3.7 and 3.8, standard library only.
πThe problem: strings, booleans, and combinations nobody meant
Here is an order model of the kind that shows up in every codebase that started by mirroring a database table:
final case class Order(
id: String,
status: String, // "draft" | "paid" | "shipped" | "cancelled"
isPaid: Boolean,
isShipped: Boolean,
isCancelled: Boolean,
transactionId: String, // "" when not paid
cancelReason: String // "" when not cancelled
)
// The compiler is perfectly happy with this.
val nonsense = Order(
id = "A-1",
status = "shpped",
isPaid = false,
isShipped = true,
isCancelled = true,
transactionId = "",
cancelReason = ""
)
Three separate failures, all accepted:
- Stringly typed.
"shpped"is a validString. The typo reaches production and falls into whatever yourcase _ =>branch does. - Boolean blindness.
isPaid/isShipped/isCancelledencode four meaningful states in eight combinations. The other four are garbage that some code path will eventually have to interpret. - Fields that only apply sometimes.
transactionIdis meaningful in exactly one state, so every reader has to know the empty-string convention.
The state and its data belong together. That is what an algebraic data type is.
πThe Scala 2 shape, and why it was annoying
Scala 2 could express the closed set. It was just verbose, and one important piece had to be maintained by hand:
sealed trait OrderStatus
case object Draft extends OrderStatus
case object Paid extends OrderStatus
case object Shipped extends OrderStatus
case object Cancelled extends OrderStatus
object OrderStatus:
// Hand-written. Nothing forces you to update it.
val all: List[OrderStatus] = List(Draft, Paid, Shipped, Cancelled)
Exhaustivity checking worked, which was the main thing. But there was no
generated list of members, no stable integer index for serialization, no
valueOf for parsing, and five lines of ceremony for a four-element set. The
all list drifting out of sync with the actual cases is a real bug I have
shipped more than once.
// scala-check: expect-error
sealed trait OrderStatus
case object Draft extends OrderStatus
case object Paid extends OrderStatus
val everything = OrderStatus.values
// ^ value values is not a member of object OrderStatusπEnums
enum OrderStatus:
case Draft, Paid, Shipped, Cancelled
@main def demo(): Unit =
println(OrderStatus.values.toList) // List(Draft, Paid, Shipped, Cancelled)
println(OrderStatus.valueOf("Shipped")) // Shipped
println(OrderStatus.Paid.ordinal) // 1
values, valueOf, and ordinal are generated. ordinal is the declaration
index, which makes it a usable β though order-sensitive β wire encoding.
Cases can take constructor parameters, which is how you attach constant data without a lookup table:
enum Currency(val code: String, val minorUnits: Int):
case Usd extends Currency("USD", 2)
case Eur extends Currency("EUR", 2)
case Jpy extends Currency("JPY", 0)
@main def demo(): Unit =
println(Currency.values.map(c => s"${c.code}/${c.minorUnits}").mkString(" "))
And the enum body can carry methods, so behavior that is a function of the case lives next to the cases:
enum Severity(val level: Int):
case Debug extends Severity(10)
case Info extends Severity(20)
case Warn extends Severity(30)
case Error extends Severity(40)
def atLeast(other: Severity): Boolean = level >= other.level
@main def demo(): Unit =
println(Severity.values.filter(_.atLeast(Severity.Warn)).toList)πEnums as full ADTs
The interesting form is when cases carry per-case data. Now the state and the fields that only make sense in that state are the same thing:
import java.time.Instant
enum OrderState:
case Draft
case Paid(transactionId: String, at: Instant)
case Shipped(transactionId: String, tracking: String)
case Cancelled(reason: String)
def summary(s: OrderState): String = s match
case OrderState.Draft => "not submitted"
case OrderState.Paid(txn, at) => s"paid $txn at $at"
case OrderState.Shipped(_, tracking) => s"in transit, tracking $tracking"
case OrderState.Cancelled(reason) => s"cancelled: $reason"
Order(id, OrderState.Shipped(...)) cannot also be cancelled, because there is
nowhere to put that. transactionId cannot be empty-string-meaning-absent,
because the states that lack a transaction do not have the field. The
empty-string convention is gone, not documented.
Transitions become total functions over the states that permit them. Note the
argument type: this is a union of two case types, so calling it with a Draft
does not compile.
enum OrderState:
case Draft
case Paid(transactionId: String)
case Shipped(transactionId: String, tracking: String)
case Cancelled(reason: String)
// Only paid or shipped orders can be refunded.
def refundTarget(s: OrderState.Paid | OrderState.Shipped): String = s match
case OrderState.Paid(txn) => txn
case OrderState.Shipped(txn, _) => txn
@main def demo(): Unit =
println(refundTarget(OrderState.Paid("txn_9")))
That | is a union type β Part 3
is about what you can and cannot do with them.
πGeneric and recursive ADTs
Enums take type parameters, so the same syntax gives you recursive trees and
Option-shaped containers:
enum Json:
case JNull
case JBool(value: Boolean)
case JNum(value: Double)
case JStr(value: String)
case JArr(items: List[Json])
case JObj(fields: List[(String, Json)])
def render(j: Json): String = j match
case Json.JNull => "null"
case Json.JBool(b) => b.toString
case Json.JNum(n) => n.toString
case Json.JStr(s) => "\"" + s.replace("\"", "\\\"") + "\""
case Json.JArr(items) => items.map(render).mkString("[", ",", "]")
case Json.JObj(fs) =>
fs.map((k, v) => s"\"$k\":${render(v)}").mkString("{", ",", "}")
@main def demo(): Unit =
val doc = Json.JObj(List(
"id" -> Json.JStr("A-1"),
"total" -> Json.JNum(12.5),
"tags" -> Json.JArr(List(Json.JStr("rush"))),
"note" -> Json.JNull
))
println(render(doc))
Add case JDate(value: java.time.Instant) to that enum and every match over
Json in the codebase warns. That is the actual payoff: the compiler produces
the to-do list.
For a parameterized ADT, a case with no parameters is automatically given the bottom type argument, which is what makes the variance work out:
enum Result[+E, +A]:
case Ok(value: A) // Result[Nothing, A]
case Err(error: E) // Result[E, Nothing]
def map[B](f: A => B): Result[E, B] = this match
case Ok(a) => Ok(f(a))
case Err(e) => Err(e)
@main def demo(): Unit =
val r: Result[String, Int] = Result.Ok(41)
println(r.map(_ + 1))
Result.Ok(1) infers Result[Nothing, Int], which is a subtype of
Result[String, Int]. Variance is doing the work here, and it has sharp edges β
Part 10 covers them.
πGotchas
values disappears the moment any case takes parameters. This surprises
people who add one data-carrying case to an existing simple enum:
value values is not a member of object OrderState.
Although class OrderState is an enum, it has non-singleton cases,
meaning a values array is not defined
ordinal still works on every case. If you need the singleton list, keep the
simple enum for the tag and put the payload elsewhere β or write the list by
hand and accept that you are back where you started.
Exhaustivity is a warning, not an error. By default this compiles:
enum OrderStatus:
case Draft, Paid, Shipped, Cancelled
def label(s: OrderStatus): String = s match
case OrderStatus.Draft => "draft"
case OrderStatus.Paid => "paid"
with only:
match may not be exhaustive.
It would fail on pattern case: Shipped, Cancelled
and then throws MatchError at runtime. Turn it into a build failure. In sbt:
scalacOptions ++= Seq("-Wall", "-Werror")
A single
case _ =>silently disables the entire benefit. It makes the match exhaustive forever, so adding a case to the enum produces no warning anywhere and the new state quietly takes the default branch. If you catch yourself writingcase _ => ???in a match over your own ADT, write out the cases β that match is the only thing that will tell you what to update later.
Java interop needs an explicit parent. Scala enums are not
java.lang.Enum subclasses by default, so they will not work with
EnumMap, EnumSet, or a Java API expecting an enum constant:
enum Currency(val code: String) extends java.lang.Enum[Currency]:
case Usd extends Currency("USD")
case Eur extends Currency("EUR")
@main def demo(): Unit =
val m = java.util.EnumMap[Currency, Int](classOf[Currency])
m.put(Currency.Usd, 1)
println(m)
This only works for enums whose cases are all singletons and which take no type parameters β the same restriction Java has.
ordinal is positional. If you persist ordinals and later insert a case in
the middle of the declaration list, you have silently rewritten history. Append
new cases at the end, or persist the name.
πWhat enums still canβt express
Enums close a set of shapes. They do not constrain values or relationships:
- Refinement of a value
Currency("USD")is fine, butcase class Money(amount: Long)still lets you add USD to JPY. Enums do not help; you need a type that is distinct at compile time and free at runtime β Part 4.- Ad-hoc sets across types
OrderState.Paid | Currencyis expressible as a union, but pattern matching on it is not exhaustivity-checked the way an enum is β Part 3.- Per-case type refinement
- In
enum Expr[A], you wantcase Lit(n: Int) extends Expr[Int]so that matching onLitteaches the compiler thatA = Intin that branch. Enums can declare it; whether the compiler exploits it is a GADT question β Part 10. - Structure-generic code
- Writing one JSON encoder that works for every enum, without a macro per type,
needs
Mirrorβ Part 6.
πNext up
Enums give you closed sets defined up front. The next question is what to do
when the set is open, or assembled from types you do not own β an API that
returns βa User or an Errorβ where neither is yours to modify.
Part 3 covers union and
intersection types, including where their exhaustivity checking stops working.