Skip to main content
Scala · lesson 3 of 11
Scala beginner 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

scala pattern-matching case-class adt enum

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.

Frequently Asked Questions

What does a case class give you over a normal class?
Structural equality and `hashCode`, a readable `toString`, a `copy` method, an `apply` so you can omit `new`, and an `unapply` that makes it usable in pattern matching. It is the default way to model data in Scala.
What is exhaustivity checking?
When matching on a sealed trait or enum, the compiler knows every possible case and warns if your match misses one. Adding a new variant then produces warnings at exactly the places that need updating, which is the main practical argument for sealed hierarchies.
How do I copy a case class with one field changed?
`order.copy(status = "shipped")` returns a new instance with everything else unchanged. Since case classes are immutable, `copy` is how you 'modify' one, and named arguments make the change explicit at the call site.
What is the difference between a sealed trait and an enum in Scala 3?
An `enum` is concise syntax for the common case where variants are simple, and it generates useful members like `values` and `ordinal`. A sealed trait with case classes is more flexible when variants need their own methods or type parameters. Both give exhaustivity checking.