Skip to content

08 · Pattern Matching Intro

Basic match

You've already seen the simplest form of match in Module 3 — matching literal values:

def dayName(day: Int): String =
  day match
    case 1 => "Monday"
    case 2 => "Tuesday"
    case 3 => "Wednesday"
    case 4 => "Thursday"
    case 5 => "Friday"
    case 6 | 7 => "Weekend"     // multiple patterns, same result
    case _ => "Invalid day"

println(dayName(6))   // Weekend
println(dayName(9))   // Invalid day

match is exhaustiveness-friendly and generally preferred over long if/else if chains once you have more than two or three branches.

Matching on type

def describe(x: Any): String =
  x match
    case i: Int => s"an Int: $i"
    case s: String => s"a String of length ${s.length}"
    case b: Boolean => s"a Boolean: $b"
    case _ => "something else"

println(describe(42))          // an Int: 42
println(describe("hello"))     // a String of length 5
println(describe(true))        // a Boolean: true
println(describe(3.14))        // something else

Destructuring case classes

This is where match really shines — you can match on a case class's shape and bind its fields to names in one step:

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

def classify(p: Point): String =
  p match
    case Point(0, 0) => "origin"
    case Point(0, _) => "on the y-axis"
    case Point(_, 0) => "on the x-axis"
    case Point(x, y) if x == y => "on the diagonal"
    case Point(x, y) => s"a regular point at ($x, $y)"

println(classify(Point(0, 0)))   // origin
println(classify(Point(0, 5)))   // on the y-axis
println(classify(Point(5, 0)))   // on the x-axis
println(classify(Point(3, 3)))   // on the diagonal
println(classify(Point(2, 7)))   // a regular point at (2, 7)

The if clauses above (case Point(x, y) if x == y => ...) are called guards — an extra condition checked only after the shape matches.

Matching on lists

def describeList(xs: List[Int]): String =
  xs match
    case Nil => "empty list"
    case x :: Nil => s"single element: $x"
    case x :: y :: Nil => s"two elements: $x and $y"
    case first :: rest => s"starts with $first, ${rest.length} more after"

println(describeList(Nil))              // empty list
println(describeList(List(5)))          // single element: 5
println(describeList(List(1, 2)))       // two elements: 1 and 2
println(describeList(List(1, 2, 3, 4))) // starts with 1, 3 more after

:: (pronounced "cons") is the same operator you used to prepend an element to a list in Module 5 — here it's used in reverse, to deconstruct a list into its head and tail.

Matching on tuples

def quadrant(point: (Int, Int)): String =
  point match
    case (0, 0) => "origin"
    case (x, y) if x > 0 && y > 0 => "quadrant I"
    case (x, y) if x < 0 && y > 0 => "quadrant II"
    case (x, y) if x < 0 && y < 0 => "quadrant III"
    case (x, y) if x > 0 && y < 0 => "quadrant IV"
    case _ => "on an axis"

println(quadrant((3, 4)))    // quadrant I
println(quadrant((-2, 5)))   // quadrant II
println(quadrant((0, 7)))    // on an axis

match as an expression

Like if/else, match produces a value — you rarely need a var to accumulate a result across branches:

val n = 7
val parity = n match
  case x if x % 2 == 0 => "even"
  case _ => "odd"

println(parity)   // odd

How It Actually Works

A match expression doesn't compile to one uniform mechanism — the compiler picks the cheapest applicable strategy per pattern shape. Matching on simple literal Ints or Strings (dense integer cases especially) compiles to the JVM's tableswitch or lookupswitch bytecode instruction — the same O(1) jump-table dispatch a Java switch compiles to, rather than a chain of if/else if comparisons. Matching on type (case s: String => ...) compiles to a sequence of instanceof checks (JVM instanceof bytecode) followed by a cast, evaluated top to bottom — which is exactly why pattern order matters and why an unreachable/overly-general earlier case can shadow a later one.

Destructuring a case class (case Point(x, y) => ...) doesn't get special runtime support either — it compiles to a call to the case class's compiler-generated unapply method (see Module 7), which returns Some((x, y)) on a structural match; the compiler then extracts the tuple's fields into the bound names x and y. For a non-case class, you get the same pattern-matching syntax only if you hand-write a matching unapply in its companion object — the language feature is really "call unapply and destructure what it returns," not anything intrinsically about case classes.

Every match also gets a scala.MatchError thrown if no case applies and there's no wildcard _ — the compiler inserts this fallback automatically, which is why exhaustiveness checking on sealed hierarchies (covered in Module 9 and Level 2's advanced pattern matching) matters: it lets the compiler prove at compile time that this runtime fallback can never actually trigger.

Cheat sheet

Pattern Matches
case 3 => The literal value 3
case 3 \| 4 => Either 3 or 4
case i: Int => Any value of type Int, bound to i
case Point(x, y) => A Point, binding its fields to x and y
case x :: rest => A non-empty list, binding head and tail
case (a, b) => A 2-tuple, binding both elements
case x if cond => Adds a guard condition after the shape matches
case _ => Matches anything (catch-all)

🔀 See this in another language

Exercise

Define case class Shape2(kind: String, a: Double, b: Double = 0.0) used to represent either a "circle" (radius in a) or a "rectangle" (width a, height b). Write a function area(shape: Shape2): Double that pattern matches on shape.kind to compute the correct area, throwing a MatchError-friendly message via case _ => throw new IllegalArgumentException(...) for unknown kinds. Test it with a few circles and rectangles.