Scala · lesson 3 of 11
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.
By SkillByExample · Published
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.