Skip to content

02 · Pattern Matching Advanced

Level 1 covered matching on literals, types, case classes, lists and tuples. This module explains how case Point(x, y) => actually works under the hood — through a mechanism called an extractor — and then uses that to write your own custom patterns, match nested structures several levels deep, and lean on sealed hierarchies so the compiler proves your matches are exhaustive.

Extractor objects: what unapply actually is

Every pattern like case Point(x, y) => is powered by a method named unapply on Point's companion object. Case classes get one generated for free, but you can write your own on a plain object to make any condition usable as a pattern — not just "is this a certain shape of data."

An unapply that just tests a condition returns Boolean:

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

def describe(n: Int): String = n match
  case Even() => s"$n is even"
  case _      => s"$n is odd"

println(describe(4))   // 4 is even
println(describe(7))   // 7 is odd

An unapply that also wants to bind values returns Option[T] (or a tuple inside the Option for multiple bindings) — None means "doesn't match," Some(...) means "matches, here are the extracted parts":

object AsPair:
  def unapply(s: String): Option[(String, String)] =
    s.split("@", 2) match
      case Array(user, domain) => Some((user, domain))
      case _                   => None

def splitEmail(s: String): String = s match
  case AsPair(user, domain) => s"user=$user domain=$domain"
  case _                    => "not an email"

println(splitEmail("ada@example.com"))   // user=ada domain=example.com
println(splitEmail("not-an-email"))      // not an email

case AsPair(user, domain) => reads exactly like matching a case class, but AsPair is a plain object — the pattern is entirely custom logic. This is how libraries hand you nice, readable patterns (case r"..." regex extractors, case NonEmptyList(head, tail) =>, etc.) without exposing you to their internal representation.

There's also unapplySeq, for patterns that extract a variable-length sequence (returning Option[Seq[T]]), used by list/varargs-style patterns like case List(a, b, rest*) =>.

Sealed hierarchies and exhaustiveness

Module 1 modeled shapes as a sealed trait with several case classes. The payoff for sealed shows up specifically at match time: the compiler knows the complete list of subtypes, so it can verify every one is handled and warn you (as an error, under -Werror, or a compiler warning otherwise) if you add a new case and forget a match somewhere:

sealed trait Json
case class JNum(value: Double) extends Json
case class JStr(value: String) extends Json
case class JArr(items: List[Json]) extends Json
case object JNull extends Json

def render(j: Json): String = j match
  case JNum(n)        => n.toString
  case JStr(s)        => s"\"$s\""
  case JArr(Nil)      => "[]"
  case JArr(items)    => "[" + items.map(render).mkString(",") + "]"
  case JNull          => "null"
  // every subtype of Json is covered -- no case _ needed

val doc: Json = JArr(List(JNum(1), JStr("hi"), JNull, JArr(List(JNum(2)))))
println(render(doc))   // [1.0,"hi",null,[2.0]]

This is the single biggest reason to prefer sealed trait + case class over an open class hierarchy for anything you plan to pattern match on: the compiler becomes a safety net that catches missing cases the moment you add a new variant, rather than at runtime via a MatchError.

Nested pattern matching

Patterns compose — you can match several levels deep in one case, which is usually clearer than chaining separate match expressions or option lookups:

case class Address(city: String, zip: String)
case class Person(name: String, address: Option[Address])

def cityOf(p: Person): String = p match
  case Person(_, Some(Address(city, _))) => city
  case Person(name, None)                => s"$name has no address"

println(cityOf(Person("Ada", Some(Address("London", "E1")))))   // London
println(cityOf(Person("Bob", None)))                             // Bob has no address

Person(_, Some(Address(city, _))) destructures the Person, then the Option, then the Address — all in a single pattern, binding only the piece you actually need (city).

@ bindings: matching and naming the whole thing

Sometimes you want to match part of a structure but still keep a handle on the whole value. The name @ pattern syntax binds name to whatever the pattern matches, while still checking the pattern's shape:

def classifyPair(p: (Int, Int)): String = p match
  case (a, b) if a == b => "equal"
  case (a @ 0, _)       => s"first is zero (bound as $a)"
  case (a, b) if a > b  => "descending"
  case _                => "ascending"

println(classifyPair((3, 3)))   // equal
println(classifyPair((0, 5)))   // first is zero (bound as 0)
println(classifyPair((5, 2)))   // descending
println(classifyPair((2, 5)))   // ascending

The trap: guards silently break exhaustiveness checking

A case _ if someCondition => guard means the compiler can no longer prove that case handles "everything else" — from the compiler's point of view, a guarded case might not fire, so it still expects a plan for what happens if every guard fails. Forgetting a final unguarded catch-all after a chain of guarded cases is one of the most common sources of a runtime MatchError in otherwise "exhaustive-looking" Scala code:

def sign(n: Int): String = n match
  case x if x > 0 => "positive"
  case x if x < 0 => "negative"
  // MatchError at runtime for n == 0 -- the compiler can't tell the guards
  // above are jointly exhaustive, so it won't warn you, and there's no
  // catch-all here to save you

Always give a guard chain a final unguarded case _ =>, exactly as you would for matching on an open (non-sealed) type.

How It Actually Works

match compiles to a decision tree, not a chain of if/else — the compiler analyzes your patterns' structure (type tags, case class field arity, sealed hierarchy membership) and can generate a jump table or nested type-tests, which is why matching is often faster than the equivalent hand-written conditionals. An extractor's unapply is called directly by the generated match code: case Some(x) => literally compiles to a call to Option's unapply, which returns a value the compiler treats as present-or-absent (historically Option[T], now often a Boolean+accessor pair for performance) — there's no reflection involved, it's a plain static method call resolved at compile time. Exhaustiveness checking works by the compiler statically enumerating every subtype of a sealed trait at compile time, which is exactly why it silently stops protecting you the moment you add a case _ if condition => guard: the compiler can prove all cases are covered, but it can't prove all values satisfy your guard, so it has to assume they might not.

Cheat sheet

Pattern What it needs Binds
case Even() => unapply returning Boolean nothing
case AsPair(a, b) => unapply returning Option[(A, B)] a, b
case List(a, b, rest*) => unapplySeq returning Option[Seq[T]] a, b, rest
case Outer(Inner(x)) => nested case class shapes x, several levels deep
case x @ Pattern => any pattern x bound to the whole matched value
case _ if cond => a guard whatever the pattern before it binds

Exercise

Write an extractor object Prime with def unapply(n: Int): Boolean that returns whether n is a prime number. Separately, model sealed trait Command with case class Move(dx: Int, dy: Int) extends Command, case class Say(msg: String) extends Command, and case object Quit extends Command. Write def run(commands: List[Command]): Unit that pattern matches on each command (print "Moving by ($dx, $dy)", print the message for Say, and print "Bye!" and stop processing further commands for Quit — hint: use recursion or takeWhile/span rather than a mutable loop). Test both with a handful of values, including at least one prime checked via your Prime extractor inside a match on an Int.