DEV Community

AranaDeDoros
AranaDeDoros

Posted on

ADTs & Enums in Scala 3: Making Illegal States a Compile Error

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]
)
Enter fullscreen mode Exit fullscreen mode

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"))
Enter fullscreen mode Exit fullscreen mode

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]
)
Enter fullscreen mode Exit fullscreen mode

The meaning of the fields depends on another field:

  • paidAt only makes sense when the order is paid.
  • shippedAt only makes sense when the order is shipped.
  • cancelReason only 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")
)
Enter fullscreen mode Exit fullscreen mode

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
  )
Enter fullscreen mode Exit fullscreen mode

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)"
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Or:

enum SubscriptionTier derives CanEqual:
  case Free, Pro, Enterprise
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

or:

sealed trait
Enter fullscreen mode Exit fullscreen mode

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]
)
Enter fullscreen mode Exit fullscreen mode

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]
)
Enter fullscreen mode Exit fullscreen mode

could potentially become:

enum Payment:
  case Pending
  case Approved(at: Instant)
  case Failed(reason: String)
Enter fullscreen mode Exit fullscreen mode

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(...)
  // ...
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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(...)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

For simple closed sets:

enum Currency:
  case USD, EUR, MXN, GBP
Enter fullscreen mode Exit fullscreen mode

For exhaustive matches:

-Xfatal-warnings
Enter fullscreen mode Exit fullscreen mode

Watch for this smell:

status: Status
foo: Option[Foo]
bar: Option[Bar]
baz: Option[Baz]
Enter fullscreen mode Exit fullscreen mode

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:

Scala 3 Domain Design & Typelevel Stack Cookbook

Top comments (0)