Case Classes and Pattern Matching
Model data with case classes and enums, take it apart with match, and let the compiler tell you which case you forgot.
Case classes model data; pattern matching takes it apart. Together with sealed hierarchies they give you something a dynamic language cannot: the compiler telling you which case you forgot to handle.
Case classes
case class Order(id: Int, customerId: Int, country: String, status: String, amount: Double)
@main def run(): Unit =
val a = Order(1001, 1, "GB", "completed", 25.50)
val b = Order(1001, 1, "GB", "completed", 25.50)
println(a)
println(a == b)
println(a eq b)
println(a.copy(status = "shipped"))
println(a)
Order(1001,1,GB,completed,25.5)
true
false
Order(1001,1,GB,shipped,25.5)
Order(1001,1,GB,completed,25.5)
Four generated behaviours. A readable toString. Structural equality — a == b is true
because the fields match, while eq (reference identity) is false. A copy that returns a
new value with one field changed. And no new needed, because Order(...) calls a generated
apply.
That last line matters: copy returned a new order and a is unchanged. Case classes are
immutable, so “modifying” one always produces a new value.
Matching
@main def run(): Unit =
val order = Order(1003, 1, "GB", "returned", 40.00)
val message = order match
case Order(id, _, _, "returned", amt) if amt > 30 =>
s"order $id: high-value return of £$amt"
case Order(id, _, _, "returned", _) =>
s"order $id: return"
case Order(id, _, "GB", "completed", _) =>
s"order $id: completed, domestic"
case Order(id, _, country, status, _) =>
s"order $id: $status in $country"
println(message)
order 1003: high-value return of £40.0
The pattern destructures and tests at once: Order(id, _, _, "returned", amt) matches only
orders whose status is returned, binding id and amt on the way. if amt > 30 is a
guard, evaluated after the shape matches. Cases are tried top to bottom, so specific ones go
first.
Other patterns worth knowing:
@main def run(): Unit =
def describe(x: Any): String = x match
case 0 => "zero"
case n: Int if n < 0 => s"negative int $n"
case n: Int => s"int $n"
case s: String => s"string of length ${s.length}"
case List(a, b) => s"two-element list: $a, $b"
case head :: tail => s"list starting $head, ${tail.length} more"
case Nil => "empty list"
case (a, b) => s"tuple of $a and $b"
case _ => "something else"
List(0, -5, 42, "hello", List(1, 2), List(1, 2, 3), Nil, (1, "a"), 3.14)
.foreach(x => println(describe(x)))
zero
negative int -5
int 42
string of length 5
two-element list: 1, 2
list starting 1, 2 more
empty list
tuple of 1 and a
something else
Type patterns (n: Int), sequence patterns (List(a, b)), the cons pattern (head :: tail)
and tuple patterns all work in the same construct. Bind a whole matched value with @:
case order @ Order(_, _, "GB", _, _) => s"domestic: $order"
Algebraic data types
The real payoff comes from a closed set of variants:
enum PaymentMethod:
case Card(last4: String, network: String)
case BankTransfer(sortCode: String, accountLast4: String)
case GiftCard(code: String, balance: Double)
case Cash
import PaymentMethod.*
def describe(p: PaymentMethod): String = p match
case Card(last4, network) => s"$network ending $last4"
case BankTransfer(sort, acct) => s"transfer from $sort ****$acct"
case GiftCard(_, balance) => f"gift card with £$balance%.2f"
case Cash => "cash"
@main def run(): Unit =
val payments = List(
Card("4242", "Visa"),
BankTransfer("04-00-04", "1234"),
GiftCard("GC-9912", 25.0),
Cash,
)
payments.foreach(p => println(describe(p)))
println(PaymentMethod.values.length)
Visa ending 4242
transfer from 04-00-04 ****1234
gift card with £25.00
cash
4
Now delete the Cash case from describe and recompile:
-- [E029] Pattern Match Exhaustivity Warning: payments.scala:12:24 -----
12 |def describe(p: PaymentMethod): String = p match
| ^
| match may not be exhaustive.
|
| It would fail on pattern case: PaymentMethod.Cash
1 warning found
The compiler knows every variant, so it knows which one you missed. Add a fifth payment method a year from now and every match in the codebase that needs updating reports itself — a refactoring aid no amount of testing gives you.
Treat that warning as an error so it cannot be ignored:
//> using option -Werror
The equivalent with a sealed trait, which you need when variants carry their own methods or type parameters:
sealed trait Shape:
def area: Double
case class Circle(r: Double) extends Shape:
def area = math.Pi * r * r
case class Rect(w: Double, h: Double) extends Shape:
def area = w * h
@main def run(): Unit =
val shapes: List[Shape] = List(Circle(1), Rect(2, 3))
shapes.foreach(s => println(f"${s.area}%.2f"))
shapes.foreach {
case Circle(r) => println(s"circle radius $r")
case Rect(w, h) => println(s"rect ${w}x$h")
}
3.14
6.00
circle radius 1.0
rect 2.0x3.0
sealed means all subtypes must be in the same file — which is what lets the compiler
enumerate them.
Matching in a lambda
@main def run(): Unit =
val orders = List(
Order(1001, 1, "GB", "completed", 25.50),
Order(1003, 1, "GB", "returned", 40.00),
)
orders.map { case Order(id, _, _, status, amount) =>
(id, status, amount * 1.2)
}.foreach(println)
val byId: Map[Int, Order] = orders.map(o => o.id -> o).toMap
byId.foreach { case (id, order) => println(s"$id -> ${order.status}") }
(1001,completed,30.599999999999998)
(1003,returned,48.0)
1001 -> completed
1003 -> returned
A block of case clauses is a function literal, which is why { case (k, v) => ... } works
directly on a Map. Note 30.599999999999998 — floating point again, and a reminder to use
BigDecimal for money rather than fixing it with a format string.
Deconstructing your own types
class Email(val value: String)
object Email:
def unapply(e: Email): Option[(String, String)] =
e.value.split("@") match
case Array(user, domain) => Some((user, domain))
case _ => None
@main def run(): Unit =
val addresses = List(new Email("ada@example.com"), new Email("not-an-email"))
addresses.foreach {
case Email(user, domain) => println(s"user=$user domain=$domain")
case other => println(s"unparseable: ${other.value}")
}
user=ada domain=example.com
unparseable: not-an-email
unapply is what a case class generates for you. Writing one by hand lets any type
participate in pattern matching — useful for validation, where the pattern either matches and
gives you the parts or does not match at all.
Practice
1. Add a variant to an enum and recompile.
enum PaymentMethod:
case Card(last4: String, network: String)
case Cash
case Crypto(chain: String)
-- [E029] Pattern Match Exhaustivity Warning: -----------------------
match may not be exhaustive.
It would fail on pattern case: PaymentMethod.Crypto(_)
1 warning found
Every incomplete match reports itself. This is the practical reason to model a closed set of options as an enum rather than as strings.
2. Use copy to change one field.
val shipped = order.copy(status = "shipped")
println(shipped)
println(order)
Order(1001,1,GB,shipped,25.5)
Order(1001,1,GB,completed,25.5)
The original is untouched. Chaining copy calls is how a pipeline updates records without
any mutation.
3. Write a match with a guard that changes which case wins.
val message = order match
case Order(id, _, _, "returned", amt) if amt > 100 => s"$id: escalate"
case Order(id, _, _, "returned", _) => s"$id: routine return"
case Order(id, _, _, _, _) => s"$id: no action"
1003: routine return
Guards run only after the shape matches, and a failed guard falls through to the next case rather than failing the whole match — which is why the general case must come last.
4. Match on head :: tail to write a recursive sum.
def sum(xs: List[Int]): Int = xs match
case Nil => 0
case head :: tail => head + sum(tail)
@main def run(): Unit =
println(sum(List(1, 2, 3, 4, 5)))
println(sum(Nil))
15
0
The two cases are the two shapes a List can have, and the compiler checks you covered both.
For real work use .sum — this recursion is stack-based and will overflow on a long list.
Next: Option, Either and Try — handling absence and failure without null.