ADTs & Enums in Scala 3
If you've worked on enough business software, you've probably seen this:
final case class Order(
status: String,
paidAt: Option[Instant],
shippedAt: Option[Instant],
cancelReason: Option[String]
)
It looks harmless.
Until you realize that this type allows all of these:
Order("pending", Some(now), None, Some("customer changed their mind"))
Order("shipped", None, None, None)
Order("cancelled", None, Some(now), Some("too expensive"))
The compiler has no idea that these combinations are nonsense.
Your domain model is effectively saying:
"An Order can have any combination of these fields, and we'll figure out later which combinations actually make sense."
That's exactly the kind of problem Algebraic Data Types (ADTs) are good at solving.
Instead of representing invalid combinations and hoping your application logic catches them, we can make those combinations impossible to construct in the first place.
The problem with "status + optional fields"
Consider a slightly more realistic order:
final case class Order(
id: OrderId,
status: String,
items: List[LineItem],
paidAt: Option[Instant],
shippedAt: Option[Instant],
cancelReason: Option[String]
)
The meaning of the fields depends on another field:
-
paidAtonly makes sense when the order is paid. -
shippedAtonly makes sense when the order is shipped. -
cancelReasononly makes sense when the order is cancelled.
But the type doesn't express any of that.
You could write:
Order(
id = orderId,
status = "pending",
items = items,
paidAt = Some(now),
shippedAt = None,
cancelReason = Some("customer changed their mind")
)
It compiles.
And now you have a paid, cancelled, pending order.
Maybe nothing immediately crashes.
Maybe six months later a reporting query produces an impossible financial state and someone has to figure out where it came from.
The problem isn't necessarily the business logic.
The problem is that the type allowed an invalid state to exist.
Model the states instead
Scala 3's enum syntax gives us a convenient way to model this as an ADT:
enum Order:
case Pending(
id: OrderId,
items: List[LineItem]
)
case Paid(
id: OrderId,
items: List[LineItem],
paidAt: Instant
)
case Shipped(
id: OrderId,
items: List[LineItem],
paidAt: Instant,
shippedAt: Instant
)
case Cancelled(
id: OrderId,
items: List[LineItem],
reason: String
)
Now look at what happened.
Pending can't have a paidAt.
Paid can't have a shippedAt.
Cancelled can't have either.
There simply isn't a field for them.
That's the important shift:
The domain constraints are now represented by the type system.
An invalid state isn't something we need to remember to validate later.
It isn't representable.
The compiler becomes part of your test suite
ADTs become particularly useful when combined with pattern matching.
def summarize(order: Order): String =
order match
case Order.Pending(id, _) =>
s"Order $id: waiting for payment"
case Order.Paid(id, _, paidAt) =>
s"Order $id: paid at $paidAt"
case Order.Shipped(id, _, _, shippedAt) =>
s"Order $id: shipped at $shippedAt"
case Order.Cancelled(id, _, reason) =>
s"Order $id: cancelled ($reason)"
The compiler knows all the possible cases of Order.
Now suppose the business comes back next month and says:
"We need to support refunded orders."
Add the case:
enum Order:
case Pending(id: OrderId, items: List[LineItem])
case Paid(id: OrderId, items: List[LineItem], paidAt: Instant)
case Shipped(
id: OrderId,
items: List[LineItem],
paidAt: Instant,
shippedAt: Instant
)
case Cancelled(id: OrderId, items: List[LineItem], reason: String)
case Refunded(id: OrderId, items: List[LineItem], refundedAt: Instant)
And suddenly all your exhaustive matches that don't handle Refunded are exposed by the compiler.
That's a huge advantage in a growing codebase.
Instead of hoping you remembered every place where order states are handled, the compiler tells you where you need to make a decision.
I strongly recommend enabling:
-Xfatal-warnings
Then a missing exhaustive case isn't merely a warning that gets buried in build output.
It's a build failure.
The compiler is now actively helping you find places where your domain model changed.
ADTs aren't just for business entities
One of my favorite applications of this technique is representing external events.
Payment providers are a great example.
You might receive JSON containing something like:
{
"type": "payment_intent.succeeded",
"data": {
"id": "pi_123",
"amount": 4999
}
}
The type determines the shape and meaning of the rest of the payload.
That's essentially an ADT hiding inside JSON.
We can make it explicit:
enum StripeEvent:
case PaymentSucceeded(
paymentIntentId: String,
amountCents: Long
)
case PaymentFailed(
paymentIntentId: String,
reason: String
)
case ChargeRefunded(
chargeId: String,
amountCents: Long
)
case Unhandled(rawType: String)
Now the rest of the application doesn't need to deal with arbitrary JSON.
The boundary of the application translates the external representation into a domain representation.
For example:
object StripeEvent:
def fromJson(json: Json): StripeEvent =
json.hcursor
.get[String]("type")
.getOrElse("unknown") match
case "payment_intent.succeeded" =>
PaymentSucceeded(
json.hcursor
.downField("data")
.get[String]("id")
.getOrElse(""),
json.hcursor
.downField("data")
.get[Long]("amount")
.getOrElse(0L)
)
case "payment_intent.payment_failed" =>
PaymentFailed(
json.hcursor
.downField("data")
.get[String]("id")
.getOrElse(""),
json.hcursor
.downField("data")
.get[String]("failure_message")
.getOrElse("unknown")
)
case "charge.refunded" =>
ChargeRefunded(
json.hcursor
.downField("data")
.get[String]("id")
.getOrElse(""),
json.hcursor
.downField("data")
.get[Long]("amount_refunded")
.getOrElse(0L)
)
case other =>
Unhandled(other)
The Unhandled case is important.
External systems change.
New Stripe event types can appear without your application being updated at the exact same time.
Instead of turning an unknown event into a MatchError, we can represent it explicitly:
case Unhandled(rawType: String)
The webhook handler can then log it, monitor it, or safely ignore it according to the application's requirements.
The boundary deals with uncertainty. The domain gets a well-defined type.
When a plain enum is enough
Not every closed set needs associated data.
Sometimes you genuinely just have a fixed set of values:
enum Currency:
case USD, EUR, MXN, GBP
Or:
enum SubscriptionTier derives CanEqual:
case Free, Pro, Enterprise
This is where Scala 3 enums feel particularly nice.
If you don't need different fields for each case, don't invent them.
Use the simplest representation that accurately models the domain.
What about sealed trait?
Scala 3 enums aren't the answer to every ADT-shaped problem.
The traditional approach still has its place:
sealed trait Payment
final case class CreditCard(...) extends Payment
final case class BankTransfer(...) extends Payment
final case class Crypto(...) extends Payment
Reach for this style when the hierarchy needs behavior or structure that doesn't fit naturally into an enum, or when you're deliberately modeling a different kind of abstraction.
The important part isn't whether you wrote:
enum
or:
sealed trait
The important part is that you've created a closed set of alternatives that the compiler can reason about.
A useful smell: Option + another field
Here's the heuristic I find particularly useful.
When you see something like:
case class Something(
state: State,
valueA: Option[A],
valueB: Option[B],
valueC: Option[C]
)
ask yourself:
"Are these options actually optional, or are they only present for certain states?"
If the answer is the latter, you may have an ADT trying to escape from your case class.
For example:
case class Payment(
status: PaymentStatus,
approvedAt: Option[Instant],
failureReason: Option[String]
)
could potentially become:
enum Payment:
case Pending
case Approved(at: Instant)
case Failed(reason: String)
The second version communicates significantly more about the domain.
And more importantly, it makes invalid combinations impossible.
Don't let your ADTs become monsters
There's a limit.
If you end up with:
enum SomeMassiveConcept:
case A(a: A, b: B, c: C, d: D, e: E, ...)
case B(a: A, b: B, c: C, d: D, e: E, ...)
case C(...)
// ...
with fifteen cases and eight fields each, that's probably a design smell.
You may be modeling several concepts as one giant sum type.
ADTs are about making the domain clearer, not about winning a competition to put your entire business into one enum.
What about CanEqual?
Scala 3 introduced stricter equality through CanEqual.
For example:
enum Currency derives CanEqual:
case USD, EUR, MXN, GBP
enum SubscriptionTier derives CanEqual:
case Free, Pro, Enterprise
This lets the compiler reject equality comparisons between unrelated types instead of silently treating them as unequal values.
That's a small feature, but it fits the same philosophy:
Push domain mistakes toward compile time whenever the type system can reasonably express them.
The bigger idea: make illegal states unrepresentable
The real value of ADTs isn't that Scala has a nicer syntax for enums.
It's this:
Before:
Order
├── status
├── paidAt?
├── shippedAt?
└── cancelReason?
↓
Many possible combinations
↓
Some are invalid
↓
Runtime validation required
After:
Order
├── Pending
├── Paid
├── Shipped
└── Cancelled
↓
Only valid shapes can be constructed
You're moving correctness from:
"We need to remember to validate this."
to:
"The compiler won't let us express it incorrectly."
That's a much stronger guarantee.
Takeaway
Every time you write an Option[X] whose presence depends on the value of another field, stop and ask whether you're looking at an ADT.
If the answer is yes, model the alternatives directly.
enum Order:
case Pending(...)
case Paid(...)
case Shipped(...)
case Cancelled(...)
Then let exhaustive pattern matching make the compiler your safety net.
The goal isn't to use ADTs everywhere.
The goal is to make your types describe the domain closely enough that invalid states have nowhere to hide.
Cheat Sheet
Use ADTs when:
- A value is exactly one of several distinct shapes.
- Different states require different fields.
- You want exhaustive pattern matching.
- You're modeling domain states, events, commands, or parsed external data.
Basic Scala 3 syntax:
enum X:
case A(fieldsForA: Type)
case B(fieldsForB: Type)
For simple closed sets:
enum Currency:
case USD, EUR, MXN, GBP
For exhaustive matches:
-Xfatal-warnings
Watch for this smell:
status: Status
foo: Option[Foo]
bar: Option[Bar]
baz: Option[Baz]
If the options depend on status, consider an ADT.
The core idea:
Make illegal states unrepresentable.
Want more Scala 3 domain modeling?
This is one of the patterns I explore in more depth in my book, Scala 3 Domain Design & Typelevel Stack Cookbook.
It's written around practical domain-design recipes rather than trying to be another general Scala introduction.
If you're interested in Scala 3, DDD, and the Typelevel ecosystem, you can find the book here:
Top comments (0)