An algebraic data type, or ADT, is a type built from two operations: and (a value has this field and that field) and or (a value is this variant or that variant). In Scala, the and is a case class and the or is a sealed trait or an enum. That sounds like a syntax note, but it is the most effective design tool in the language: when the shape of your types matches the shape of your domain, the compiler rejects whole categories of bugs before a test runs.
The mechanics of case classes themselves, such as apply, unapply, copy and equality, are covered in Scala case classes. This article is about designing with them: counting the states a type allows, removing the illegal ones, choosing between a sealed trait and an enum, making exhaustivity checks fail the build, evolving an ADT that other code depends on, and knowing when an ADT is the wrong tool.
Sums, products and counting states
The names come from arithmetic. A product type has as many possible values as the product of its fields' counts: a case class of a Boolean and a three-valued enum has 2 times 3, so 6, values. A sum type has as many values as the sum of its variants' counts: a sealed trait with a Boolean-carrying variant and a three-valued variant has 2 plus 3, so 5. Option[A] is 1 plus A; Either[E, A] is E plus A.
Counting is not academic. If your domain has 4 legal states and your type admits 32, the other 28 are bugs waiting for a code path to produce them. The design goal is to make the count of the type equal the count of the domain.
Making illegal states unrepresentable
The classic smell is a record with several optional fields whose combinations are constrained by comments. A payment row with an optional auth code, an optional capture time and an optional failure reason admits eight combinations of present and absent, and the code that reads it must defend against all of them. Turn the states into variants and each variant carries exactly the fields that exist in that state.
// Before: 2^3 flag combinations, most of them nonsense.
final case class PaymentRow(
id: String, amount: BigDecimal,
authCode: Option[String], capturedAt: Option[Instant], failure: Option[String])
// Is authCode = None, capturedAt = Some(t) legal? The type says yes. The business says no.
// After: only the four real states exist.
sealed trait Payment { def id: PaymentId }
object Payment {
final case class Pending(id: PaymentId, amount: Money) extends Payment
final case class Authorized(id: PaymentId, amount: Money, authCode: AuthCode) extends Payment
final case class Captured(id: PaymentId, amount: Money, authCode: AuthCode,
capturedAt: Instant) extends Payment
final case class Failed(id: PaymentId, reason: FailureReason) extends Payment
// Transitions only accept the state they start from.
def authorize(p: Pending, code: AuthCode): Authorized = Authorized(p.id, p.amount, code)
def capture(a: Authorized, at: Instant): Captured = Captured(a.id, a.amount, a.authCode, at)
}Two things changed beyond tidiness. A captured payment now cannot exist without an auth code, so no reader has to check. And the transition functions take the precise source state, so capture cannot be called on a pending payment; the mistake does not compile. Small wrapper types such as PaymentId and Money finish the job by stopping argument mix-ups; opaque types give you those with no runtime cost.
What sealed means, precisely
A sealed trait or class can only be directly extended in the same source file. That single rule lets the compiler enumerate the direct subtypes at every match site. It does not seal the subtypes: a non-sealed intermediate trait can be extended anywhere, which reopens the variant set. Mark intermediate traits sealed and leaf case classes final, so outside code cannot subclass them either.
Putting variants in the companion object, as above, keeps the namespace tidy (Payment.Pending) and keeps them in the required file.
Sealed trait or Scala 3 enum
Scala 3 enums are ADTs with less ceremony. Every enum gets an ordinal; enums whose cases are all singletons also get values and valueOf. Cases with parameters work too, and the compiler gives them a sealed parent, so exhaustivity works the same way.
// Scala 3 enum: concise, with ordinal, and values/valueOf when every case is a singleton.
enum Side derives CanEqual:
case Buy, Sell
// Enum with parameterised cases is also an ADT.
enum Shape:
case Circle(r: Double)
case Rect(w: Double, h: Double)
def area(s: Shape): Double = s match
case Shape.Circle(r) => math.Pi * r * r
case Shape.Rect(w, h) => w * h
// Reach for a sealed trait instead when variants need their own methods, extra
// parents, nested sub-hierarchies, or must be shared with Scala 2 code.
sealed trait Event
sealed trait AccountEvent extends Event
final case class Opened(id: String) extends AccountEvent
final case class Closed(id: String) extends AccountEvent
final case class Heartbeat(at: Long) extends EventPrefer an enum for flat sets of variants that are mostly data. Prefer a sealed trait when variants need their own methods or extra parents, when a nested hierarchy lets a function accept only AccountEvent, or when the type must be visible from Scala 2 code. One subtle difference: enum case constructors return the enum type, so Shape.Circle(1) has static type Shape, while a case class under a sealed trait keeps its precise type. That is usually what you want for an enum and occasionally a surprise.
Exhaustivity: what the compiler checks and what it cannot
With a sealed parent, a match that misses a variant produces a warning naming an example of the missing case. Note the word warning: by default the build still succeeds. Make it an error.
def describe(p: Payment): String = p match
case Payment.Pending(_, amt) => s"pending $amt"
case Payment.Authorized(_, amt, _) => s"authorised $amt"
case Payment.Captured(_, amt, _, _) => s"captured $amt"
// Forgot Failed: the compiler warns that the match may not be exhaustive
// and names the missing case, e.g. Payment.Failed(_, _).
def risky(p: Payment): String = p match
case a: Payment.Authorized if a.amount.isLarge => "review"
case _: Payment.Authorized => "ok" // needed: a guard can fail
case _ => "n/a" // silences the check forever
// build.sbt: make the warning a build failure.
// Scala 3: scalacOptions ++= Seq("-Werror")
// Scala 2.13: scalacOptions ++= Seq("-Xfatal-warnings")Three things defeat the check. A guard is assumed to be able to fail, so a case with if does not count as covering its pattern; add an unguarded case for the same variant. A wildcard case _ covers everything, including variants added next year, so the compiler will never tell you about them; avoid wildcards in matches over your own ADTs. And @unchecked on the scrutinee switches the check off; reserve it for cases you can prove, and leave a comment saying why. Type tests on erased type parameters, such as case xs: List[Int], are unchecked at runtime and produce their own warning; treat that warning as a bug too.
Both Scala 2.13 and Scala 3 let you promote individual warning categories with -Wconf if a blanket fatal-warnings flag is too strict for a legacy codebase. Turning on exhaustivity as an error for one module at a time is a reasonable migration plan.
Smart constructors: validation at the edges
ADTs fix the shape; they do not fix values. A Money field can still hold a negative amount. Put validation in a constructor that returns a sum type of its own, and make the raw constructor private so the only way in is through it.
final case class Email private (value: String)
object Email:
def parse(raw: String): Either[String, Email] =
val s = raw.trim.toLowerCase
if s.count(_ == '@') == 1 && !s.startsWith("@") && !s.endsWith("@") then Right(Email(s))
else Left(s"not an email: $raw")In Scala 3 a private constructor also makes the synthesised apply and copy private, so callers cannot bypass the check with copy. Scala 2.13 behaves the same way only with the Scala 3 source flag, so check your version rather than assume. Parse untrusted input once, at the boundary, into these types, and the rest of the program never re-validates.
Recursive ADTs and folds
ADTs can refer to themselves, which is how trees, expressions, JSON and query plans are modelled. The natural way to consume them is a fold: one function per variant, combined recursively. Writing the fold once means each new interpretation, such as evaluate, pretty-print or optimise, is a few lines and is exhaustive by construction.
sealed trait Expr
object Expr:
final case class Num(n: Int) extends Expr
final case class Add(l: Expr, r: Expr) extends Expr
final case class Mul(l: Expr, r: Expr) extends Expr
final case class Neg(e: Expr) extends Expr
def fold[A](e: Expr)(num: Int => A, add: (A, A) => A, mul: (A, A) => A, neg: A => A): A =
def go(x: Expr): A = x match
case Num(n) => num(n)
case Add(l, r) => add(go(l), go(r))
case Mul(l, r) => mul(go(l), go(r))
case Neg(x) => neg(go(x))
go(e)
def eval(e: Expr): Int = fold(e)(identity, _ + _, _ * _, -_)
def show(e: Expr): String = fold(e)(_.toString, (a, b) => s"($a + $b)", (a, b) => s"$a * $b", a => s"-$a")The direct recursion here is not tail-recursive, so a very deep tree, say a list-shaped expression of a hundred thousand nodes built by a parser, can overflow the stack. For trees of untrusted depth, either limit depth at parse time or rewrite the fold with an explicit stack or a trampoline. For balanced trees of ordinary size the plain version is fine and far easier to read.
Generic programming over ADTs
Because a sealed hierarchy is a closed list, the Scala 3 compiler can describe it to your code at compile time through scala.deriving.Mirror. A Mirror.SumOf[T] exposes the variant labels and types and an ordinal method; a Mirror.ProductOf[T] does the same for a case class's fields. Libraries use this to derive JSON codecs, equality, ordering and schema generation.
import scala.deriving.Mirror
import scala.compiletime.constValueTuple
// The compiler synthesises a Mirror.SumOf for every sealed trait and enum.
inline def variantNames[T](using m: Mirror.SumOf[T]): List[String] =
constValueTuple[m.MirroredElemLabels].toList.map(_.toString)
variantNames[Payment] // List(Pending, Authorized, Captured, Failed)
// m.ordinal(p) gives the variant index of a value: handy for metrics tags.Most teams never write derivation themselves; they write derives clauses and let a library such as circe do it. The value of knowing the mechanism is debugging: when derivation fails, it is almost always because some variant is not a case class or case object, or some field type has no instance.
Evolving an ADT that others depend on
Adding a variant is the change that ADTs make visible. Inside one codebase this is the feature: every non-exhaustive match lights up and you fix them all before shipping. Across a library boundary it is a breaking change for callers who match exhaustively, so treat a new variant as a semver-major event for public ADTs, or document that the type is open to extension and that callers must keep a fallback case.
On the wire, the same tension appears. A JSON or protobuf encoding of an ADT needs a discriminator, a field naming the variant. An old consumer that receives a variant it does not know will fail to decode. For events that flow between services, give the consumer-side ADT an explicit Unknown(tag, raw) variant that the decoder falls back to, so new producers can ship before every consumer upgrades and unknown events are logged rather than crashing a stream. Never reuse a discriminator value for a different shape, and never rename one without a reader that accepts both.
When an ADT is the wrong tool
ADTs make adding operations easy and adding variants costly: a new function is one new match, but a new variant touches every match. Classic object-oriented subtyping is the mirror image: a new subclass is local, a new operation touches every class. This is the expression problem. If your variants are open-ended and supplied by plug-ins, use a trait with methods or a type class instead; tagless final is one route for effectful programs. If the set is closed and the operations grow, which describes most business domains, an ADT wins. For more on how these features sit in the wider type system, see the Scala type system.
Pitfalls at a glance
| Pitfall | Consequence | Fix |
|---|---|---|
| Unsealed intermediate trait | Variant set reopened elsewhere; exhaustivity unreliable | Seal intermediates, finalise leaves |
| Wildcard case in a match over your own ADT | New variants silently take the default path | List every variant explicitly |
| Exhaustivity left as a warning | Missing cases reach production | -Werror or -Wconf for the category |
| Booleans and Options encoding state | Illegal combinations representable | One variant per real state |
| Public case class constructor with invariants | Invalid values created through apply or copy | Private constructor plus a parsing function |
| Decoder with no unknown-variant path | Rolling deploys break consumers | Unknown fallback variant, log and skip |
What to do next
- Turn on fatal warnings for exhaustivity in at least one module this week: -Werror on Scala 3, -Xfatal-warnings on 2.13, or a targeted -Wconf rule.
- Search your codebase for case classes with three or more Option fields and redraw each as a sealed sum of the real states.
- Mark every leaf case class final and every intermediate trait sealed.
- Remove wildcard cases from matches over your own ADTs and let the compiler list what was missing.
- Wrap identifiers and validated strings in opaque types or private-constructor case classes with a parse method.
- For event ADTs crossing service boundaries, add an Unknown variant on the consumer side and a test that decodes a future variant.
- Write one fold for your most-matched recursive ADT and express two interpretations through it.