Escaping Boolean hell and making Illegal States Unrepresentable in Scala 2.13
"Make illegal states unrepresentable" is Yaron Minsky's line, from the Jane Street post Effective ML Revisited (March 2011). He wrote it about OCaml variants, but the idea carries to any language with sum types: Scala sealed traits, Rust enums, Haskell data types, TypeScript unions.
I read a short explainer of this idea this week, with a Scala example. The idea is right. Before building on it I compiled every claim on Scala 2.13.18, the version the cvnext backend uses, and five of them needed a caveat. This post is the idea plus those caveats.
Four fields, sixteen states
The "before" model is a bag of flags and options:
final case class UserRow(
isLoggedIn: Boolean,
email: Option[String],
guestId: Option[String],
errorMsg: Option[String]
)
Count each Option as present or absent and each Boolean as two values: 2 × 2 × 2 × 2 = 16 combinations. (The explainer said 2³, but four fields give 2⁴.) Only three combinations mean something: logged in with an email and nothing else, guest with an id and nothing else, and nobody. The other thirteen compile, serialize and can reach a database.
The errorMsg field is also in the wrong place. An error is not a property of a user. It is the failure of an operation that was supposed to produce one.
The same model as a sum type
sealed trait User extends Product with Serializable
object User {
final case class LoggedIn(email: Email) extends User
final case class Guest(guestId: String) extends User
case object Anonymous extends User
def greeting(u: User): String = u match {
case LoggedIn(email) => s"Welcome back, ${email.value}"
case Guest(id) => s"Hello, guest $id"
case Anonymous => "Please sign in"
}
}
Three types, three shapes. A LoggedIn has an email by construction. A Guest cannot carry an error message, because the field does not exist. Sixteen states became three.

The hole: a valid shape with an invalid value
If LoggedIn took a String, then LoggedIn("") would compile. The type fixed the shape, not the value. Closing that gap takes a smart constructor, and in Scala 2.13 it takes more than a private constructor. I tried the obvious version, final case class Email private (value: String). The constructor is private, but Email("") and .copy(value = "") both still compile, because the generated apply and copy stay public.
The version that works is a sealed abstract case class with a factory in the companion:
sealed abstract case class Email private (value: String)
object Email {
private val Shape = """[^@\s]+@[^@\s]+\.[^@\s]+""".r
def from(raw: String): Either[String, Email] = {
val s = raw.trim
if (Shape.matches(s)) Right(new Email(s) {})
else Left(s"not an email address: '$raw'")
}
}
With that, Email("x") fails with "Email.type does not take parameters", .copy fails with "value copy is not a member of Email", and new Email("x") {} fails with "constructor Email in class Email cannot be accessed". The only way to get an Email is Email.from, which returns an Either. Scala 3 has opaque types for the same job, but they are not available in 2.13.
One door in
Real data still arrives in the old shape: a database row, a session blob, a JSON body. The conversion happens once, at the edge, and returns an Either. This is the same idea as the Either post in my Scala series:
def fromRow(row: UserRow): Either[String, User] =
(row.isLoggedIn, row.email, row.guestId, row.errorMsg) match {
case (true, Some(e), None, None) => Email.from(e).map(LoggedIn(_))
case (false, None, Some(g), None) => Right(Guest(g))
case (false, None, None, None) => Right(Anonymous)
case other => Left(s"inconsistent row: $other")
}
I ran it on four rows:
fromRow(UserRow(true, Some("zak@example.com"), None, None)) // Right(LoggedIn(Email(zak@example.com)))
fromRow(UserRow(true, Some(""), None, None)) // Left(not an email address: '')
fromRow(UserRow(true, None, Some("g1"), None)) // Left(inconsistent row: (true,None,Some(g1),None))
fromRow(UserRow(false, None, None, None)) // Right(Anonymous)
Thirteen impossible states are rejected in one match. Everything after that point works with a User and never re-checks the flags.
Exhaustivity is a warning, and three habits switch it off
The compiler checks match expressions on a sealed type, so adding a variant later shows every place that has to change. I added Suspended and left greeting alone:
case object Suspended extends User // a new variant
def greeting(u: User): String = u match {
case LoggedIn(email) => s"Welcome back, ${email.value}"
case Guest(id) => s"Hello, guest $id"
case Anonymous => "Please sign in"
}
The compiler reports:
warning: match may not be exhaustive.
It would fail on the following input: Suspended
That is a warning, not an error. The build still succeeds and the missing case is a MatchError at runtime. To make it an error, add this to build.sbt:
ThisBuild / scalacOptions += "-Werror"
I checked that -Werror turns the warning into a failed compile on 2.13.18. The cvnext backend sets -deprecation, -feature and -unchecked but not -Werror, so a non-exhaustive match there would be a warning in the sbt output today.
Two more habits defeat the check, and -Werror does not help with the first:
u match {
case LoggedIn(email) => s"Welcome back, ${email.value}"
case _ => "Please sign in" // Suspended lands here, silently
}
u match {
case LoggedIn(email) if email.value.nonEmpty => "Welcome back"
case Guest(_) => "Hello, guest"
case Anonymous => "Please sign in"
} // warning: match may not be exhaustive
A wildcard case _ compiles without any warning, even after Suspended exists, so it silently handles the new variant. A guard (if) is opaque to the checker, so the match is reported as incomplete. List every case instead of using a wildcard on a sealed type.

The Scala 2 detail: Product with Serializable
Without extends Product with Serializable on the sealed trait, Scala 2 infers an awkward type for a collection of its cases:
sealed trait Plain // no "extends Product with Serializable"
final case class A(x: String) extends Plain
case object B extends Plain
List(A("a"), B) // inferred: List[Product with Plain with java.io.Serializable]
That type leaks into signatures and error messages. Declare sealed trait User extends Product with Serializable and the inferred type is List[User]. A sealed abstract class does not avoid this by itself: I compiled one without the extends and got the same awkward type. Scala 3 no longer needs this.
The payment example needs a rewrite
The explainer modelled payment methods as Card, PayPal and Crypto. The sum type is good. The Card(number: String, cvv: String) case is not:
final case class Card(number: String, cvv: String) extends PaymentMethod
println(Card("4111111111111111", "123")) // Card(4111111111111111,123)
The case class toString prints both values, so any log line that mentions the payment method stores the card number and the verification code. PCI DSS v4 requirement 3.3.1.2 says the card verification code must not be stored after authorization (requirement 3 breakdown). The model should hold what the payment processor returns instead:
final case class CardToken(value: String) extends PaymentMethod {
override def toString = "CardToken(***)"
}
println(CardToken("tok_abc")) // CardToken(***)
The number and the code never enter the application, and a logged value is safe by default. The same lesson applies again: a String field says nothing about what the value is, so it is the wrong type for anything sensitive.
Where I would use this in cvnext
A search of the Scala backend finds no sealed trait today, so this section is a list of candidates, not finished work.
- The signed-in principal.
AuthenticatedUserholdsprovider: Stringand anattributes: Map[String, Any]. Machine credentials are anAuthenticatedUserwhose provider is the string"blog-token"or"focus-token", and ownership is derived by comparing the email. A sealedPrincipalwith one case per credential kind would put "which credential is this" in the type. Any change here would have to keep the exact-allowlist tests green. - Outbox status.
MailOutboxwrites'PUBLISHED','PROCESSING','FAILED'and'RESUBMITTED'as SQL string literals. The wire values must stay exactly as they are so the Spring image can read the same rows, so each case would carry its wire name and an unknown value would fail the load. For cases without data, enumeratum is the lighter option.
What the compiler checks and what it leaves to you
- Shape: a sealed trait with one case class per valid state.
- Values: a smart constructor, and in 2.13 a
sealed abstract case classso thatapplyandcopyare gone. - Coverage:
-Werror, and no wildcard on a sealed type. - Inference:
extends Product with Serializablein Scala 2. - Secrets: never a plain
Stringfield, becausetoStringwill print it.
Removing impossible states pays off most when the edge of the system is strict. The fromRow function above is where that happens, and it is the only place that has to know the old shape existed.