Free Handbook · Every example compiled & verified

Case Classes & Pattern Matching

Case classes, sealed traits and ADTs, exhaustive match, every kind of pattern, list and Option patterns, custom extractors and enums with parameters.

0 / 142 lessons🔥 0 day streak
ShareXLinkedIn

Module 05 · what you'll be able to do

  • Model data with case classes and use their free equality, toString and copy
  • Build an algebraic data type from a sealed trait or an enum and let the compiler check every match covers it
  • Read and write constant, variable, typed, tuple, constructor, guard, alternative and @ patterns
  • Take lists apart with head :: tail and handle Option with Some and None
  • Write your own extractor object with unapply and use patterns in val, for and lambdas
01

Case classes

Most classes in a Scala program are plain data: a user, an order, an event. Put case in front of class and the compiler writes the boilerplate for you. Every parameter becomes a public val; == compares the contents, not identity; toString prints the fields; hashCode agrees with == so instances work as Map keys and in Sets; copy makes a modified copy; and a companion with apply and unapply is generated, which is what makes case classes work in patterns.

scalaMain.scala
case class User(name: String, age: Int, role: String = "member")

@main def run(): Unit =
  val a = User("Asha", 30)
  val b = User("Asha", 30)
  println(a)
  println(a == b)
  println(a.name)

  val older = a.copy(age = 31)
  val admin = a.copy(role = "admin")
  println(older)
  println(admin)
  println(a)

  println(Set(a, b, older).size)
Outputcompiled & run with real Scala
User(Asha,30,member)
true
Asha
User(Asha,31,member)
User(Asha,30,admin)
User(Asha,30,member)
2

Compare this with the plain class in Module 04, where two objects with the same fields were not equal. copy uses named and default arguments: name the fields you want to change, and the rest are copied. a itself never changes.

Your turn

Put a and admin into a Set and print its size. Are they equal?

Error you will hit

Reassigning a case class field

scala
case class Point(x: Int, y: Int)

@main def run(): Unit =
  val p = Point(1, 2)
  p.x = 10
  println(p)
-- [E052] Type Error: Main.scala:5:6
5 |  p.x = 10
  |  ^^^^^^^^
  |  Reassignment to val x
1 error found
Compilation failed
Why the compiler said that

Case class parameters are vals: a case class is an immutable value, like the number 5. Nobody holding a reference to p can have it change under them, which is what makes case classes safe to share between functions and threads.

The fix

Make a new value with copy. It is cheap, and the old value stays valid for anyone still using it.

scala
case class Point(x: Int, y: Int)

@main def run(): Unit =
  val p = Point(1, 2)
  val moved = p.copy(x = 10)
  println(moved)
In real jobs
Case classes are the default shape for domain data, API request and response bodies, Kafka messages, configuration and Spark Datasets — see Spark with Scala. You can write case class Counter(var n: Int), but almost nobody does: a mutable field breaks the "equal now means equal forever" promise that Set and Map rely on.
02

Sealed traits and ADTs

An algebraic data type (ADT) says "a value of this type is exactly one of these shapes". In Scala you build one from a sealed trait plus a case class (or case object, for a shape with no data) for each alternative. sealed means every subtype must be declared in the same file, so the compiler knows the complete list — and can therefore warn you when a match forgets one.

scalaMain.scala
sealed trait Payment
case class Card(last4: String, amount: Double) extends Payment
case class Upi(handle: String, amount: Double) extends Payment
case object Cash extends Payment

def describe(p: Payment): String = p match
  case Card(last4, amt) => s"card ending $last4 paid $amt"
  case Upi(handle, amt) => s"UPI $handle paid $amt"
  case Cash             => "paid in cash"

@main def run(): Unit =
  val payments = List(Card("4242", 99.0), Upi("asha@bank", 250.0), Cash)
  payments.map(describe).foreach(println)
Outputcompiled & run with real Scala
card ending 4242 paid 99.0
UPI asha@bank paid 250.0
paid in cash

Each case checks which shape the value has and pulls its fields out into new names in one step. Cash is a case object — a single value, so it is matched by name.

Your turn

Add case class Wallet(provider: String, amount: Double) extends Payment and compile. Read the warning, then add the missing case.

Error you will hit

Non-exhaustive match: a warning now, a MatchError later

scala
sealed trait Shape
case class Circle(r: Double) extends Shape
case class Rect(w: Double, h: Double) extends Shape
case class Triangle(b: Double, h: Double) extends Shape

def area(s: Shape): Double = s match
  case Circle(r)  => math.Pi * r * r
  case Rect(w, h) => w * h

@main def run(): Unit =
  println(area(Rect(2, 3)))
  println(area(Triangle(4, 5)))
-- [E029] Pattern Match Exhaustivity Warning: Main.scala:6:29
6 |def area(s: Shape): Double = s match
  |                             ^
  |                             match may not be exhaustive.
  |
  |                             It would fail on pattern case: Triangle(_, _)
1 warning found
6.0
Exception in thread "main" scala.MatchError: Triangle(4.0,5.0) (of class Triangle)
	at Main$package$.area(Main.scala:8)
	at Main$package$.run(Main.scala:12)
	at run.main(Main.scala:10)
Why the compiler said that

Because Shape is sealed, the compiler knows Triangle exists and tells you exactly which case is missing. It is only a warning, so the program still compiles and runs: the first call works, and the second throws scala.MatchError at runtime when no case fits. Without sealed, you would not even get the warning.

The fix

Handle the missing shape. Treat exhaustivity warnings as errors — many teams compile with -Werror so they cannot be ignored.

scala
sealed trait Shape
case class Circle(r: Double) extends Shape
case class Rect(w: Double, h: Double) extends Shape
case class Triangle(b: Double, h: Double) extends Shape

def area(s: Shape): Double = s match
  case Circle(r)      => math.Pi * r * r
  case Rect(w, h)     => w * h
  case Triangle(b, h) => b * h / 2

@main def run(): Unit =
  println(area(Rect(2, 3)))
  println(area(Triangle(4, 5)))
Do not silence the check with case _
A final case _ => makes any match "exhaustive", but it also means that when someone adds a new shape next year the compiler stays quiet and the new shape falls into the default branch. For a sealed type, list every case explicitly and let the compiler do the remembering.
03

Every kind of pattern

The left side of a case is a pattern. Patterns nest — a tuple pattern can hold constructor patterns that hold constants — so a handful of kinds covers almost everything. Cases are tried top to bottom and the first match wins.

PatternExampleMatches
Wildcardcase _Anything, binds nothing
Constant / literalcase 0, case "quit", case NilValues equal to that constant
Variablecase nAnything, and names it n (lowercase names only)
Typedcase s: StringValues of that runtime type, already cast
Tuplecase (x, 0)A tuple whose parts match the inner patterns
Constructorcase Rect(w, h)A case class instance; its fields match the inner patterns
Guardcase n if n > 100The pattern, and only if the condition is true
Alternativescase "y" | "yes"Any one of the patterns (they may not bind variables)
Bindercase r @ Rect(_, _)The inner pattern, and names the whole value r
scalaMain.scala
case class Rect(w: Int, h: Int)

def classify(x: Any): String = x match
  case 0                     => "zero"
  case "y" | "yes"           => "agreed"
  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 (a, 0)                => s"pair ending in zero, first $a"
  case (a, b)                => s"pair $a and $b"
  case Rect(w, h) if w == h  => s"square of side $w"
  case r @ Rect(_, 1)        => s"thin $r"
  case Rect(w, h)            => s"rect ${w * h}"
  case other                 => s"something else: $other"

@main def run(): Unit =
  val inputs = List(0, "yes", -7, 42, "hello", ("k", 0), (1, 2),
    Rect(3, 3), Rect(9, 1), Rect(2, 5), 3.5)
  inputs.foreach(x => println(classify(x)))
Outputcompiled & run with real Scala
zero
agreed
negative int -7
int 42
string of length 5
pair ending in zero, first k
pair 1 and 2
square of side 3
thin Rect(9,1)
rect 10
something else: 3.5

Order matters: the guarded n: Int if n < 0 must come before the plain n: Int, or it would never be reached. r @ Rect(_, 1) checks the shape and still gives you the whole rectangle. Matching on Any is for demonstration — real code matches on a sealed type so the compiler can check coverage.

Your turn

Add a case for a Boolean that prints "yes" or "no", above case other.

Error you will hit

A lowercase name in a pattern is a new variable, not a comparison

scala
val admin = "admin"

def role(name: String): String = name match
  case admin => "full access"
  case _     => "read only"

@main def run(): Unit =
  println(role("guest"))
-- [E030] Match case Unreachable Warning: Main.scala:5:7
5 |  case _     => "read only"
  |       ^
  |       Unreachable case
1 warning found
full access
Why the compiler said that

The intent was "if the name equals the value admin". But a lowercase identifier in a pattern is a variable pattern: it matches anything and binds it to a new name that happens to shadow the outer admin. So the first case always wins, "guest" gets full access, and the compiler warns that case _ can never run. It is only a warning — the bug ships if you ignore it.

The fix

Put the name in backticks to compare against the existing value (or give the constant an uppercase name, such as Admin, which Scala always treats as a constant).

scala
val admin = "admin"

def role(name: String): String = name match
  case `admin` => "full access"
  case _       => "read only"

@main def run(): Unit =
  println(role("guest"))
Typed patterns and generics
The JVM erases type arguments at runtime, so case xs: List[Int] can only check "is it a List", and Scala warns that the Int part is unchecked. Match on List[?], or better, on your own case classes, which carry their real type.
04

List patterns: head :: tail

An immutable List is either empty — Nil — or a first element (the head) joined to the rest of the list (the tail) with ::. Patterns use the same shapes: case Nil for empty, case head :: tail for "at least one element". List(a, b) matches exactly two elements, and List(first, rest*) matches one or more. Together with recursion this is how you process a list one element at a time.

scalaMain.scala
def sum(xs: List[Int]): Int = xs match
  case Nil          => 0
  case head :: tail => head + sum(tail)

def describe(xs: List[String]): String = xs match
  case Nil                  => "nobody"
  case List(one)            => one
  case List(a, b)           => s"$a and $b"
  case first :: second :: _ => s"$first, $second and ${xs.length - 2} more"

def firstTwo(xs: List[Int]): String = xs match
  case List(a, b, rest*) => s"$a, $b (+${rest.length})"
  case _                 => "too short"

@main def run(): Unit =
  println(sum(List(3, 4, 5)))
  println(describe(Nil))
  println(describe(List("Asha")))
  println(describe(List("Asha", "Ravi")))
  println(describe(List("Asha", "Ravi", "Meera", "Ken")))
  println(firstTwo(List(9, 8, 7, 6)))
  println(firstTwo(List(1)))
Outputcompiled & run with real Scala
12
nobody
Asha
Asha and Ravi
Asha, Ravi and 2 more
9, 8 (+2)
too short
Your turn

Write def lastOf(xs: List[Int]): Option[Int] with three cases: Nil, List(x), and _ :: tail.

Visualizesum(List(3, 4, 5))Step 1 / 7
def sum(xs: List[Int]): Int = xs match
case Nil => 0
case head :: tail => head + sum(tail)
println(sum(List(3, 4, 5)))
Line 5

sum is called with the whole list.

Variables now
xsList(3, 4, 5)
All 7 steps as a table
StepLineWhat happenedVariables now
15sum is called with the whole list.xs = List(3, 4, 5)
23Not Nil, so head :: tail matches: head is 3, tail is the rest. It needs sum(tail) before it can add.head = 3 tail = List(4, 5)
33Inside the second call: head 4, tail List(5). Still waiting on the next call.head = 4 tail = List(5)
43Third call: head 5, tail is empty.head = 5 tail = List()
52Fourth call gets Nil and returns 0 — the base case.xs = List()
63The calls unwind: 5 + 0 = 5, then 4 + 5 = 9, then 3 + 9 = 12.result = 12
75The result is printed.
Recursion here is for learning
This sum is not tail recursive, so a list of a million elements would overflow the stack; the accumulator fix from Module 03 applies. In everyday code you would write xs.sum or xs.foldLeft(0)(_ + _) — Module 07. The patterns themselves stay useful for "empty, one, or more" decisions.
05

Matching on Option

Option[A] is itself a tiny ADT: either Some(value) or None. Scala APIs return it instead of null whenever a value might be missing — map.get(key), list.find(...), "42".toIntOption. A match with both cases forces you to decide what "missing" means, and the compiler warns if you forget None.

scalaMain.scala
val stock = Map("apple" -> 12, "pear" -> 0)

def check(item: String): String = stock.get(item) match
  case Some(0) => s"$item: sold out"
  case Some(n) => s"$item: $n left"
  case None    => s"$item: we do not sell that"

@main def run(): Unit =
  List("apple", "pear", "kiwi").foreach(i => println(check(i)))

  val parsed = List("7", "x", "35").map(_.toIntOption)
  println(parsed)
  val total = parsed.map {
    case Some(n) => n
    case None    => 0
  }.sum
  println(total)
Outputcompiled & run with real Scala
apple: 12 left
pear: sold out
kiwi: we do not sell that
List(Some(7), None, Some(35))
42

Patterns nest: Some(0) matches only an Option holding zero. The map { case ... } form is a lambda written as a match — more on that in the last lesson.

Beyond match
Matching on every Option gets verbose. getOrElse, map, fold and for-comprehensions handle most cases in one line, and Either and Try carry an error instead of just "nothing". That is Module 08.
06

Extractor objects: your own unapply

A constructor pattern like Card(last4, amt) works because the case class companion has an unapply method: given a value, it says whether it matches and, if so, what the parts are. You can write unapply on any object to make your own extractor — a pattern that parses, validates or views data in a different shape. Return Option[T] to extract a value, or Boolean for a pattern that just tests.

scalaMain.scala
object Email:
  def unapply(s: String): Option[(String, String)] =
    s.split("@") match
      case Array(user, domain) if user.nonEmpty && domain.contains(".") => Some((user, domain))
      case _ => None

object Even:
  def unapply(n: Int): Boolean = n % 2 == 0

def route(s: String): String = s match
  case Email(user, "example.com") => s"internal user $user"
  case Email(user, domain)        => s"external user $user at $domain"
  case _                          => s"not an email: $s"

@main def run(): Unit =
  List("[email protected]", "[email protected]", "no-at-sign").foreach(s => println(route(s)))
  for n <- List(3, 4) do
    n match
      case Even() => println(s"$n is even")
      case _      => println(s"$n is odd")
Outputcompiled & run with real Scala
internal user asha
external user ravi at mail.org
not an email: no-at-sign
3 is odd
4 is even

Email(user, "example.com") mixes the extractor with a constant pattern on the domain. A Boolean extractor is written with empty parentheses: Even().

Your turn

Write an extractor Positive whose unapply(n: Int): Boolean matches ints greater than zero, and use it in a match.

07

Enums with parameters

Scala 3 enums (introduced in Module 04) grow in two directions. An enum can take constructor parameters shared by every case, so each case carries fixed data. And individual cases can take their own parameters, which makes the enum a compact ADT — the same thing as a sealed trait with case classes, in fewer lines, and matched exactly the same way.

scalaMain.scala
enum Plan(val pricePerMonth: Int, val seats: Int):
  case Free       extends Plan(0, 1)
  case Team       extends Plan(49, 10)
  case Enterprise extends Plan(299, 100)

  def pricePerSeat: Double = if seats == 0 then 0 else pricePerMonth.toDouble / seats

@main def run(): Unit =
  for p <- Plan.values do
    println(f"$p%-10s ${p.pricePerMonth}%4d/month  ${p.pricePerSeat}%.2f per seat")
Outputcompiled & run with real Scala
Free          0/month  0.00 per seat
Team         49/month  4.90 per seat
Enterprise  299/month  2.99 per seat

Every case is built with extends Plan(...), and values still lists them in order.

scalaMain.scala
enum Shape:
  case Circle(r: Double)
  case Rect(w: Double, h: Double)
  case Point

import Shape.*

def area(s: Shape): Double = s match
  case Circle(r)  => math.Pi * r * r
  case Rect(w, h) => w * h
  case Point      => 0

@main def run(): Unit =
  val shapes = List(Circle(1), Rect(2, 3), Point)
  shapes.foreach(s => println(f"$s%-14s ${area(s)}%.2f"))
  println(Rect(2, 3) == Rect(2, 3))
  println(Rect(2, 3).copy(h = 10))
Outputcompiled & run with real Scala
Circle(1.0)    3.14
Rect(2.0,3.0)  6.00
Point          0.00
true
Rect(2.0,10.0)

Cases with parameters behave like case classes — equality, toString, copy and patterns all work. import Shape.* brings the cases into scope so you can write Circle(1) instead of Shape.Circle(1). An enum with parameterised cases has no values, because its cases are not a fixed list of single values.

Your turn

Add case Triangle(b: Double, h: Double), compile, read the exhaustivity warning, and handle it.

enum

  • Shortest syntax for a closed set of alternatives
  • values, ordinal, valueOf for simple cases
  • Cases share one namespace: Shape.Circle
  • Great for statuses, commands, small ADTs

sealed trait + case classes

  • Each alternative is a full class: its own methods, traits, companion
  • Subtypes can themselves be sealed traits (nested hierarchies)
  • The Scala 2 style, still everywhere in libraries
  • Better when alternatives differ a lot in behaviour
08

Patterns outside match

Patterns are not limited to match. A val can destructure a tuple or case class; a for generator can take each element apart; and a block of cases in braces is a lambda — a partial function — that you can pass to map, foreach or collect. collect is the useful one: it keeps only the elements some case matches and transforms them in one pass.

scalaMain.scala
case class Order(id: Int, customer: String, total: Double)

@main def run(): Unit =
  val (min, max) = (3, 9)
  println(s"range $min..$max")

  val Order(id, who, _) = Order(7, "Asha", 120.0)
  println(s"order $id by $who")

  val scores = List(("Asha", 91), ("Ravi", 78), ("Meera", 85))
  for (name, score) <- scores do println(s"$name scored $score")

  val passed = scores.collect { case (name, s) if s >= 80 => name.toUpperCase }
  println(passed)

  val mixed: List[Any] = List(1, "two", 3, "four")
  println(mixed.collect { case n: Int => n * 10 })
Outputcompiled & run with real Scala
range 3..9
order 7 by Asha
Asha scored 91
Ravi scored 78
Meera scored 85
List(ASHA, MEERA)
List(10, 30)

Destructuring with val is safe when the pattern always matches — a tuple of two, or a case class. With collect, elements no case matches are simply skipped; with map they would throw MatchError.

Your turn

Use collect on mixed to keep only the strings, reversed.

Case class
A class whose parameters are vals and which gets equals, hashCode, toString, copy, apply and unapply generated.
copy
A generated method that returns a new case class instance with some fields changed by name.
Case object
A singleton object with case-class conveniences; used for data-less alternatives such as Cash or Nil.
sealed
A modifier that restricts subtypes to the same file, so the compiler knows every alternative.
ADT
Algebraic data type: a type that is exactly one of a fixed set of shapes, each with its own fields.
Exhaustive match
A match that covers every possible shape of the value; the compiler checks this for sealed types and enums.
MatchError
The runtime exception thrown when no case of a match fits the value.
Variable pattern
A lowercase name in a pattern; matches anything and binds it. Use backticks to compare with an existing value instead.
Guard
An if condition after a pattern; the case applies only when it is true.
Binder (@)
name @ pattern: matches the pattern and also names the whole value.
Extractor
An object with an unapply method, usable as a pattern.
Partial function
A function defined only for some inputs, written as a block of case clauses; used by collect.
Quick check

What does List(1, 2, 3) match { case a :: b :: _ => a + b; case _ => 0 } return?

Quick check

A match on a sealed trait is missing one subtype. What happens?

Frequently asked questions

What is the difference between a class and a case class in Scala?
A case class makes every constructor parameter a public val and generates structural equals and hashCode, a readable toString, copy, and a companion with apply and unapply for pattern matching. A plain class gets none of that: == compares identity and its parameters are private unless marked val. Use case classes for immutable data, plain classes for objects with behaviour and hidden state.
Why use a sealed trait in Scala?
sealed limits subtypes to the same file, so the compiler knows every possible alternative. That lets it warn when a match misses a case, which turns "we added a new payment type and forgot one place" from a runtime MatchError into a compile-time warning.
Should I use a Scala 3 enum or a sealed trait?
Use an enum for a closed set of simple alternatives — statuses, commands, small ADTs — because it is shorter and gives values and ordinal for data-less cases. Use a sealed trait with case classes when alternatives need their own methods, traits or companions, or when you need nested hierarchies.

Finish the Scala handbook, then get hired

Sit the exam for your certificate, run your resume through the ATS checker, and see the jobs that ask for exactly this.

Check my resume
Found this course useful? Share it.
ShareXLinkedIn

Comments

0

Join the conversation. Sign in to leave a comment — we'd love to hear your thoughts.