Skip to content
Algebraic Data Types in Practice
step 1/7

Reading — step 1 of 7

Learn

~3 min readADTs, Monads, Implicits

FP in Scala and the Scala docs treat algebraic data types (ADTs) as the foundation of domain modeling. The combo of sealed trait + case class is Scala's killer pattern for closed families of data.

Sum types — sealed trait + case classes

A value is exactly ONE of several alternatives:

sealed trait PaymentMethod
case class CreditCard(number: String, cvv: String) extends PaymentMethod
case class BankTransfer(account: String, routing: String) extends PaymentMethod
case class Cash(amount: Double) extends PaymentMethod
case object Free extends PaymentMethod         // singleton — no payload
  • sealed — restricts subtypes to this file (compiler can verify exhaustiveness)
  • Each subtype is either a case class (with data) or case object (without)
  • The compiler warns on non-exhaustive matches

Pattern matching is now type-safe

def process(p: PaymentMethod): String = p match {
    case CreditCard(num, _) => s"charging card ending ${num.takeRight(4)}"
    case BankTransfer(acc, _) => s"transfer from $acc"
    case Cash(amt) => s"cash $$$amt"
    case Free => "no charge"
}

Add a new case (PayPal, etc.) to the trait → every match in the codebase issues a warning until updated. Refactor-safe.

Product types — case classes alone

A value contains ALL of several fields:

case class User(name: String, email: String, age: Int)
case class Address(street: String, city: String, zip: String)

Product types model "this AND that AND that." Sum types model "this OR that OR that."

Composing — sums of products

Real domains combine both:

sealed trait Event
case class UserSignup(userId: String, email: String, timestamp: Long) extends Event
case class OrderPlaced(orderId: String, userId: String, amount: Double) extends Event
case class ItemViewed(userId: String, itemId: String) extends Event

An Event is a sum (one of three) where each variant is a product (multiple fields). This is the canonical shape of domain models.

Encoding state machines

ADTs shine for modeling lifecycle:

sealed trait OrderState
case object Cart extends OrderState
case class PendingPayment(method: PaymentMethod) extends OrderState
case class Confirmed(txId: String) extends OrderState
case class Shipped(trackingNumber: String) extends OrderState
case class Delivered(deliveredAt: Long) extends OrderState
case class Cancelled(reason: String, cancelledAt: Long) extends OrderState

def nextState(state: OrderState, event: Event): OrderState = (state, event) match {
    case (Cart, AddPayment(method)) => PendingPayment(method)
    case (PendingPayment(_), PaymentSucceeded(tx)) => Confirmed(tx)
    case (Confirmed(_), Ship(tn)) => Shipped(tn)
    // ... etc
    case (s, _) => s   // ignore invalid transitions
}

Illegal transitions become COMPILE-TIME impossibilities — you can't construct Shipped from Cart because the case patterns don't match.

Compared to enum (Scala 3)

Scala 3 introduces native enum syntax that compiles to the same sealed-trait pattern:

// Scala 3 syntax:
enum PaymentMethod:
    case CreditCard(number: String, cvv: String)
    case BankTransfer(account: String, routing: String)
    case Cash(amount: Double)
    case Free

Functionally equivalent — just less ceremony.

Recursive ADTs — trees, expressions

sealed trait Tree[+A]
case object Leaf extends Tree[Nothing]
case class Node[A](value: A, left: Tree[A], right: Tree[A]) extends Tree[A]

def depth[A](t: Tree[A]): Int = t match {
    case Leaf => 0
    case Node(_, l, r) => 1 + math.max(depth(l), depth(r))
}

Trees, JSON, AST, expression evaluators — all natural ADTs.

Common mistakes

  • Forgetting sealed — without it, the compiler can't verify exhaustive matching; you can subclass anywhere.
  • Adding methods to one variant without thinking about all — better to define methods on the sealed trait or use pattern matching dispatch.
  • Mixing inheritance with ADTs — keep the hierarchy SHALLOW. Two levels max for clarity.
  • Big ADTs (10+ variants) — gets hard to maintain. Consider splitting into smaller ADTs.
  • Using ADT for things that should be classes with behavior — ADTs are for DATA. Classes with state and methods are still classes.

Discussion

Ask a question, share an insight, or help someone who’s stuck.

Sign in to post a comment or reply.

Loading…