Skip to main content
Scala beginner Lesson 3 of 10

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("[email protected]"), 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.