Scala Algebraic Data Types

Algebraic Data Types (ADTs) are a way to model data precisely using combinations of two building blocks: sum types (a value is one of several variants) and product types (a value holds several fields together). In Scala, sealed traits create sum types and case classes create product types. Together they let you model complex domains with types that are exhaustive, composable, and pattern-matchable.

Product Types — AND

A product type combines multiple fields. A Person has a name AND an age AND an email. Case classes are product types:

case class Person(name: String, age: Int, email: String)
// A Person = name AND age AND email

case class Point(x: Double, y: Double)
// A Point = x AND y

The number of possible values is the product of each field's possibilities — hence the name "product type."

Sum Types — OR

A sum type is a value that can be exactly one of several variants. A Shape is a Circle OR a Rectangle OR a Triangle — not all three at once:

sealed trait Shape
case class Circle(radius: Double)              extends Shape
case class Rectangle(width: Double, h: Double) extends Shape
case class Triangle(base: Double, h: Double)   extends Shape
// A Shape = Circle OR Rectangle OR Triangle

SUM TYPE (OR):                     PRODUCT TYPE (AND):
────────────────────────────────   ────────────────────────────────
sealed trait Shape                 case class Circle(radius: Double)
  Circle OR Rectangle OR Triangle    = one Double (the radius)

One variant at a time              All fields present simultaneously

Combining ADTs

// A payment can be paid by Card OR Cash OR BankTransfer
sealed trait PaymentMethod
case class Card(last4: String, expiry: String)  extends PaymentMethod
case class Cash(amount: Double)                 extends PaymentMethod
case class BankTransfer(accountNo: String, ifsc: String) extends PaymentMethod

// An order status is one of these variants
sealed trait OrderResult
case class Success(orderId: String, method: PaymentMethod) extends OrderResult
case class Failure(code: Int, reason: String)              extends OrderResult

def processOrder(result: OrderResult): Unit = result match
  case Success(id, Card(last4, exp)) =>
    println(s"Order $id paid by card ending $last4 (exp: $exp)")
  case Success(id, Cash(amount)) =>
    println(f"Order $id paid by cash: ₹$amount%.2f")
  case Success(id, BankTransfer(acc, ifsc)) =>
    println(s"Order $id via bank transfer (${acc.take(4)}xxxx, $ifsc)")
  case Failure(code, reason) =>
    println(s"Order failed [$code]: $reason")

processOrder(Success("ORD001", Card("4532", "12/26")))
// Order ORD001 paid by card ending 4532 (exp: 12/26)

processOrder(Failure(402, "Insufficient funds"))
// Order failed [402]: Insufficient funds

Recursive ADTs — Trees

ADTs can be recursive. A binary tree is either a Leaf or a Node that contains two sub-trees:

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]

//        5
//       / \
//      3   8
//     / \   \
//    1   4   9

val tree: Tree[Int] =
  Node(5,
    Node(3, Node(1, Leaf, Leaf), Node(4, Leaf, Leaf)),
    Node(8, Leaf, Node(9, Leaf, Leaf))
  )

def sum(t: Tree[Int]): Int = t match
  case Leaf           => 0
  case Node(v, l, r)  => v + sum(l) + sum(r)

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

println(sum(tree))     // 30
println(depth(tree))   // 3

Tree[Int]
  │
  ├── Leaf                        (base case)
  └── Node(value, left, right)    (recursive case)

Expression ADT

sealed trait Expr
case class Num(value: Double)            extends Expr
case class Add(left: Expr, right: Expr)  extends Expr
case class Mul(left: Expr, right: Expr)  extends Expr
case class Neg(expr: Expr)               extends Expr

def eval(e: Expr): Double = e match
  case Num(v)    => v
  case Add(l, r) => eval(l) + eval(r)
  case Mul(l, r) => eval(l) * eval(r)
  case Neg(x)    => -eval(x)

// Represents: (3 + 4) * -(2 + 1)
val expr = Mul(Add(Num(3), Num(4)), Neg(Add(Num(2), Num(1))))
println(eval(expr))   // -21.0

def show(e: Expr): String = e match
  case Num(v)    => v.toString
  case Add(l, r) => s"(${show(l)} + ${show(r)})"
  case Mul(l, r) => s"(${show(l)} * ${show(r)})"
  case Neg(x)    => s"-(${show(x)})"

println(show(expr))   // ((3.0 + 4.0) * -((2.0 + 1.0)))

ADTs in Domain Modeling

sealed trait UserStatus
case object Active    extends UserStatus
case object Suspended extends UserStatus
case class  Banned(reason: String, until: Option[String]) extends UserStatus

case class User(
  id: Int,
  name: String,
  email: String,
  status: UserStatus
)

def canLogin(user: User): Boolean = user.status match
  case Active       => true
  case Suspended    => false
  case Banned(_, _) => false

def statusMessage(user: User): String = user.status match
  case Active             => s"Welcome, ${user.name}!"
  case Suspended          => "Your account is suspended. Contact support."
  case Banned(r, Some(d)) => s"Banned until $d: $r"
  case Banned(r, None)    => s"Permanently banned: $r"

val u1 = User(1, "Alice", "a@b.com", Active)
val u2 = User(2, "Bob",   "b@b.com", Banned("Policy violation", Some("2025-01-01")))

println(statusMessage(u1))   // Welcome, Alice!
println(statusMessage(u2))   // Banned until 2025-01-01: Policy violation
println(canLogin(u1))        // true
println(canLogin(u2))        // false

ADTs make illegal states unrepresentable. A user cannot be both Active and Banned. A shape cannot be both a Circle and a Rectangle. The type system enforces these constraints at compile time, eliminating entire categories of runtime bugs.

Leave a Comment

Your email address will not be published. Required fields are marked *