Skip to content
← All insights
Scala type system17 min read

Algebraic data types in Scala: model the valid states

How case classes, sealed traits and enums form product and sum types, how they remove invalid combinations, and what they cost at API, persistence and evolving-codebase boundaries.

Algebraic data types combine values with and or

An algebraic data type is built from product types and sum types. A product contains several values together: an Account has an id and a name and a balance. A sum represents one alternative from a closed set: a PaymentResult is Accepted or Declined.

The names come from counting possibilities. If a Boolean has two possible values and a three-case status has three, a product of them has six combinations. A sum has the possibilities of each alternative added together.

This vocabulary matters because data shape controls which states the program can construct. Scala case classes are a natural product representation; sealed traits with case classes or Scala 3 enums provide closed sums.

Build products and sums directlyscala 2.13
final case class Receipt(id: ReceiptId, amount: BigDecimal)

sealed trait PaymentResult
object PaymentResult {
  final case class Accepted(receipt: Receipt) extends PaymentResult
  final case class Declined(reason: DeclineReason) extends PaymentResult
}

// Receipt is a product: id AND amount.
// PaymentResult is a sum: Accepted OR Declined.

Replace optional-field combinations with real alternatives

A record with status flags and several Option fields may permit combinations that make no business sense: a completed import without a completion time, a failed import without an error, or a pending import carrying both.

A sum type gives each state exactly the data available in that state. Code no longer checks a Boolean and then hopes the related Option is defined. Constructing the case is the proof that its required fields exist.

Do not create a new case for every incidental display difference. Split the model when the alternatives carry different invariants or drive different behaviour, not merely to avoid all optional values.

Make impossible combinations unrepresentablescala 2.13
sealed trait ImportState
object ImportState {
  case object Pending extends ImportState
  final case class Running(startedAt: Instant) extends ImportState
  final case class Completed(rows: Int, finishedAt: Instant) extends ImportState
  final case class Failed(error: ImportError, failedAt: Instant) extends ImportState
}

// There is no Completed value without rows and finishedAt,
// and no Failed value without an ImportError.

Exhaustive matching turns model growth into compiler feedback

When a hierarchy is closed, the compiler can warn when a pattern match omits a case. Adding Cancelled to ImportState identifies the decisions that need reconsidering: presentation, persistence, metrics and state transitions.

Avoid a wildcard case when every domain alternative deserves a decision. A default branch silences that feedback and can quietly assign future cases an inappropriate behaviour.

Exhaustivity is most useful when the component owns the complete decision. Scattering matches across the codebase can make every new case expensive. Keep presentation decisions in presenters, persistence encoding in codecs and domain transitions in the domain module.

Let a match document the complete decisionscala 2.13
def canRestart(state: ImportState): Boolean = state match {
  case ImportState.Pending          => false
  case ImportState.Running(_)       => false
  case ImportState.Completed(_, _)  => false
  case ImportState.Failed(_, _)     => true
}

// Adding another direct case to ImportState prompts this decision.

Smart constructors constrain products as well as sums

A case class can still admit invalid field values. AccountName(String) may contain whitespace and PositiveAmount(BigDecimal) may be negative unless construction is controlled.

Keep the raw constructor private and expose a function returning Either or Option. Once created, the value carries its invariant through the rest of the application. This is a refinement of the product rather than a new runtime validation at every use.

Use constrained values where they prevent genuine mistakes or centralise a meaningful rule. Wrapping every String creates conversion and naming overhead without automatically improving the model.

Construct only a valid amountscala 2.13
final case class PositiveAmount private (value: BigDecimal)

object PositiveAmount {
  def from(value: BigDecimal): Either[String, PositiveAmount] =
    Either.cond(
      value > 0,
      PositiveAmount(value),
      "amount must be positive"
    )
}

PositiveAmount.from(BigDecimal(25)) // Right(...)
PositiveAmount.from(BigDecimal(0))  // Left(...)

State transitions can encode permitted movement

A closed state model prevents invalid snapshots, but a public constructor may still let any caller jump directly from Pending to Completed. Important workflows also need to control which transitions are legal and which evidence they require.

Name transition functions around domain actions. They can accept the current state and return Either[TransitionError, NextState], keeping rejection explicit. For very strict workflows, separate state-specific types can make only valid operations available, though that increases generic and persistence complexity.

Choose the level that matches the consequences. A small import job may need one total transition function. A regulated workflow may justify stronger state-specific APIs and audit evidence.

Make an invalid transition a typed resultscala 2.13
sealed trait StartError
object StartError {
  case object AlreadyStarted extends StartError
}

def start(
  state: ImportState,
  at: Instant
): Either[StartError, ImportState] = state match {
  case ImportState.Pending =>
    Right(ImportState.Running(at))
  case _ =>
    Left(StartError.AlreadyStarted)
}

Scala 3 enums make the same model more direct

Scala 3 enums can define simple cases and cases carrying data in one declaration. They remain sum types and support exhaustive pattern matching. Scala 2 sealed traits and case classes express the same design with more declarations.

Use the syntax appropriate to the supported compiler. A Scala 2 to Scala 3 migration does not need to rewrite every sealed hierarchy immediately; preserving serialisation and behaviour is more important than adopting enum syntax.

Java interoperability also matters. Public APIs consumed by Java may benefit from simpler classes and explicit visitor-style methods rather than exposing Scala-specific generated shapes directly.

Express a closed result in Scala 3scala 3
enum PaymentResult:
  case Accepted(receipt: Receipt)
  case Declined(reason: DeclineReason)

def status(result: PaymentResult): String = result match
  case PaymentResult.Accepted(_) => "accepted"
  case PaymentResult.Declined(_) => "declined"

Translate external input before it reaches the model

JSON, database documents and third-party responses are untrusted representations. They can contain unknown discriminators, missing fields and combinations the current domain no longer accepts. A codec should validate and translate them rather than assuming deserialisation proves a domain value is valid.

In Play JSON, a transport Reads can decode the external shape and then call a smart constructor. The HTTP boundary maps structural and domain validation errors to a stable response. The service receives the ADT only after that work succeeds.

Keep wire discriminators and domain case names separate when external compatibility matters. Renaming a Scala case should not silently rename persisted or public values unless that compatibility change is intentional.

Keep the wire representation explicitscala 2.13 / Play JSON
def readsResult: Reads[PaymentResult] =
  (__ \ "status").read[String].flatMap {
    case "accepted" =>
      (__ \ "receipt").read[Receipt].map(PaymentResult.Accepted)
    case "declined" =>
      (__ \ "reason").read[DeclineReason].map(PaymentResult.Declined)
    case other =>
      Reads(_ => JsError(s"unknown payment status: $other"))
  }

// Wire values remain stable even if internal class names change.

Persistence turns a closed model into a migration concern

A new ADT case is a source-code change and potentially a data-schema change. Older application versions may not understand documents written by a newer version, and exhaustive current code may still fail on an unknown stored discriminator.

Plan rolling deployment compatibility. Readers may need to accept old and new forms before writers emit the new form. Database indexes and queries must account for where case-specific fields live, and migrations should identify records that cannot be translated.

Derived codecs reduce boilerplate but do not remove this decision. Inspect the exact stored or public representation and protect it with integration tests before refactoring the hierarchy.

ADTs trade flexibility for precision

A closed sum makes permitted alternatives explicit and gives the compiler complete knowledge. The cost is that adding a case requires coordinated changes and may be inappropriate for a plugin API whose users must add their own implementations.

Large nested ADTs can also produce verbose matches, codecs and error types. If every operation immediately converts the model into strings and booleans, its precision may not be earning its cost. Group decisions by ownership and avoid a single application-wide hierarchy for unrelated concerns.

Use open traits for genuine extension points and closed ADTs for vocabularies the application owns. Model states that change behaviour or required data; leave incidental attributes as ordinary fields. The goal is not the largest type model—it is to make important invalid states difficult or impossible to express.

  • Does each case carry different required data or behaviour?
  • Does the application own the complete set of alternatives?
  • Will a new case require API or persistence compatibility?
  • Are matches located with the decisions they own?
  • Would one optional field be clearer than another hierarchy?
  • Does the model prevent a real invalid state?