Technology  /  Scala

๐Ÿ”ด Scala 21 guides ยท updated 2026

The JVM's hybrid OOP/functional language โ€” immutability, pattern matching, and the type system that powers Spark and Akka, from first principles to production.

Pattern Matching in Scala: Match Expressions, Extractors and Exhaustiveness

match has appeared throughout this series already โ€” narrowing a valueโ€™s type, destructuring a case class, handling Some/None. This guide slows down and covers pattern matching as a complete, standalone feature: every pattern shape it supports, how guards refine a match further, and the exhaustiveness checking that turns โ€œdid I handle every caseโ€ from a manual review task into a compile-time guarantee.


match as a Richer switch โ€” But Itโ€™s an Expression

def dayType(day: String): String = day match {
case "Saturday" | "Sunday" => "Weekend"
case "Monday" | "Tuesday" | "Wednesday" | "Thursday" | "Friday" => "Weekday"
case _ => "Unknown"
}

Like a Java switch, match compares a value against several cases โ€” but as covered in the variables and types guide, match is an expression: it evaluates to a value directly, with no fall-through between cases (each case is independent, no break needed or possible), and the | operator lets one case handle multiple alternative values cleanly.


Matching on Literal Values and Types

def describe(x: Any): String = x match {
case 0 => "zero"
case n: Int if n > 0 => "positive integer"
case n: Int => "negative integer"
case s: String => s"a string of length ${s.length}"
case true | false => "a boolean"
case _ => "something else"
}

Matching on a type (case n: Int =>) both tests whether the value is that type and binds it to a new name (n) with that narrowed type available inside the case body โ€” this is exactly the type-narrowing mechanism covered in the TypeScript seriesโ€™ narrowing guide, expressed here as Scalaโ€™s native pattern-matching syntax rather than an if (typeof x === ...) check. Order matters: cases are checked top to bottom, and the first matching case wins โ€” case 0 => must come before case n: Int if n > 0 =>, or 0 would incorrectly fall into whichever broader case came first.


Guards โ€” Adding a Condition to a Pattern

def categorize(age: Int): String = age match {
case a if a < 13 => "child"
case a if a < 20 => "teenager"
case a if a < 65 => "adult"
case _ => "senior"
}

A guard (if after the pattern) adds an extra boolean condition that must also be true for the case to match โ€” without it, match can only test structure and type, not arbitrary conditions. Guards are what let pattern matching subsume most of what a chain of if/else if would otherwise handle, while keeping the โ€œcompare one value against several possibilitiesโ€ framing that makes match more readable than a long if chain for this kind of branching.


Destructuring Case Classes

case class Point(x: Double, y: Double)
def describe(p: Point): String = p match {
case Point(0, 0) => "origin"
case Point(x, 0) => s"on the x-axis at $x"
case Point(0, y) => s"on the y-axis at $y"
case Point(x, y) if x == y => "on the diagonal"
case Point(x, y) => s"at ($x, $y)"
}

This is the destructuring behavior covered mechanically in the companion objects guide โ€” each case Point(...) calls Point.unapply under the hood, extracting x and y (or matching them against literal values like 0) directly in the pattern. Combining literal-value matching, variable binding, and guards in the same pattern (as the four cases above show) is where pattern matching becomes genuinely more expressive than an equivalent if/else chain would be โ€” each case reads as a direct description of the shape being matched.


Matching on Collections

def describeList(list: List[Int]): String = list match {
case Nil => "empty list"
case head :: Nil => s"single element: $head"
case head :: second :: Nil => s"two elements: $head and $second"
case head :: tail => s"starts with $head, followed by ${tail.length} more"
}
describeList(List()) // "empty list"
describeList(List(1)) // "single element: 1"
describeList(List(1, 2)) // "two elements: 1 and 2"
describeList(List(1, 2, 3)) // "starts with 1, followed by 2 more"

:: (cons) in a pattern destructures a List into its head and tail, mirroring the :: construction operator from the functions guideโ€™s recursion examples โ€” this is the standard way to write recursive functions over lists, matching the empty case (Nil) as the base case and the head :: tail case as the recursive step.

// Fixed-length matching, and matching with a rest-of-collection binding
val point = List(3, 4)
point match {
case List(x, y) => println(s"($x, $y)")
case _ => println("not a 2D point")
}
val numbers = List(1, 2, 3, 4, 5)
numbers match {
case first :: second :: rest => println(s"first two: $first, $second; rest: $rest")
case _ => println("fewer than 2 elements")
}

Sealed Traits and Exhaustiveness Checking

sealed trait PaymentMethod
case class CreditCard(number: String) extends PaymentMethod
case class PayPal(email: String) extends PaymentMethod
case object Cash extends PaymentMethod
def processingFee(method: PaymentMethod): Double = method match {
case CreditCard(_) => 2.50
case PayPal(_) => 1.00
case Cash => 0.0
}

Because PaymentMethod is sealed (introduced in the traits guide), every possible subtype is known to the compiler at compile time โ€” the complete set is closed, since every subtype must be declared in the same file. This is exactly what lets the compiler verify exhaustiveness: if someone later adds case class BankTransfer(...) extends PaymentMethod and forgets to update processingFee, the compiler emits a warning (or an error, with strict settings) that the match isnโ€™t exhaustive โ€” catching a missed case at compile time, months before it would otherwise surface as a MatchError at runtime in production.

// What happens WITHOUT sealed โ€” no exhaustiveness checking at all
trait UnsealedPaymentMethod
case class Check(number: String) extends UnsealedPaymentMethod
def fee(method: UnsealedPaymentMethod): Double = method match {
case Check(_) => 1.50
// No warning here, even though other subtypes could exist anywhere in the codebase or a future file
}
fee(new UnsealedPaymentMethod {}) // Compiles fine, but throws scala.MatchError at runtime!

yes

no โ€” a case is missing

sealed trait PaymentMethod

Compiler knows the COMPLETE set of subtypes

match on PaymentMethod without a default case

Every subtype handled?

Compiles cleanly, no warning

Compiler warning/error:

match may not be exhaustive

This is arguably the single most valuable practical reason to reach for sealed trait + case class/case object over a plain, unsealed hierarchy: it converts โ€œdid the developer remember to update every place this type is matchedโ€ from a manual, error-prone process into something the compiler actively checks for you.


Extractor Objects โ€” Custom Matching Logic

object Even {
def unapply(n: Int): Option[Int] = if (n % 2 == 0) Some(n) else None
}
object Positive {
def unapply(n: Int): Boolean = n > 0 // a Boolean-returning unapply is valid too โ€” no value to extract
}
def classify(n: Int): String = n match {
case Even(v) if v > 100 => s"large even number: $v"
case Even(_) => "even number"
case Positive() => "positive odd number"
case _ => "non-positive odd number"
}

This extends the extractor object pattern from the companion objects guide โ€” unapply can return Option[T] (extracting a value, as Even does) or plain Boolean (a pure yes/no test with nothing to extract, as Positive does, matched with empty parentheses Positive()). Custom extractors let you build pattern-matching vocabulary specific to your own domain, beyond what case classes generate automatically.

// A genuinely common real-world extractor: regex matching
val datePattern = """(\d{4})-(\d{2})-(\d{2})""".r
"2026-07-20" match {
case datePattern(year, month, day) => println(s"Year: $year, Month: $month, Day: $day")
case _ => println("Not a valid date format")
}

.r compiles a String into a Regex object, which itself defines an unapply extracting matched capture groups โ€” this is exactly the extractor mechanism, applied by the standard library to regular expressions, and itโ€™s why regex matching in a match expression looks so natural in Scala compared to manually calling .matches() and then separately extracting groups.


Pattern Matching in for Comprehensions and Function Parameters

val pairs = List((1, "one"), (2, "two"), (3, "three"))
for ((num, word) <- pairs) {
println(s"$num -> $word")
}
// Partial functions using match syntax directly as a lambda
val describe: Int => String = {
case 0 => "zero"
case n if n > 0 => "positive"
case _ => "negative"
}

A lambda written as { case ... => ... } directly (without an explicit match) is sugar for a function that pattern-matches its single argument โ€” this shorthand is extremely common in collection operations, especially when destructuring tuples inside a map/filter call (list.map { case (a, b) => a + b }), avoiding an extra named parameter just to immediately destructure it.


Nested Pattern Matching

sealed trait Shape
case class Circle(radius: Double) extends Shape
case class Rectangle(width: Double, height: Double) extends Shape
case class Drawing(shape: Shape, color: String)
def describe(d: Drawing): String = d match {
case Drawing(Circle(r), "red") if r > 10 => s"large red circle, radius $r"
case Drawing(Circle(r), color) => s"$color circle, radius $r"
case Drawing(Rectangle(w, h), color) => s"$color rectangle, ${w}x$h"
}

Patterns nest arbitrarily deep โ€” Drawing(Circle(r), "red") destructures the outer Drawing case class and its nested Shape field in a single pattern, rather than requiring a separate nested match for the inner value. This is one of pattern matchingโ€™s most practically valuable capabilities for real domain models: a deeply nested structure can be destructured and tested in one expression instead of a pyramid of nested conditionals.

Common Pitfalls

SymptomLikely cause
A case that should match never runsAn earlier, broader case matches first โ€” reorder from most-specific to least-specific
scala.MatchError at runtimeNo case _ => fallback, and an unhandled value reached the match โ€” check for sealed + exhaustiveness, or add a default case
Guard condition seems ignoredThe guardโ€™s variable binding is scoped incorrectly, or the guard references a value from an outer scope that shadows the bound pattern variable
Regex extractor doesnโ€™t match at allForgot .r when compiling the string into a Regex, or the pattern isnโ€™t anchored/escaped as expected
Exhaustiveness warning appears after adding a new case classWorking as intended โ€” the compiler is telling you every match over that sealed trait needs the new case added

Frequently Asked Questions

Does the order of cases in a match expression matter? Yes, always โ€” cases are checked top to bottom, and the first matching case wins, with no fall-through. A broader case placed before a more specific one will silently โ€œstealโ€ matches the specific case was meant to handle.

What happens if no case matches? A scala.MatchError is thrown at runtime โ€” this is exactly the failure mode sealed traits with exhaustiveness checking are designed to prevent at compile time; for non-sealed types or genuinely incomplete matches, always include a case _ => fallback unless youโ€™re certain every case truly is covered.

Can I match against multiple values at once? Yes, using a tuple: (x, y) match { case (0, 0) => ... ; case (a, 0) => ... } โ€” this is a common pattern for conditional logic depending on more than one value simultaneously, avoiding nested match expressions.

Is match always the right tool instead of if/else? Not universally โ€” a single boolean condition is usually clearer as a plain if. match earns its place once youโ€™re branching on a valueโ€™s shape (its type, its structure, which case class variant it is) rather than a simple true/false condition.

Why does exhaustiveness checking require sealed specifically? Because without sealed, the compiler has no way to know the complete set of possible subtypes โ€” new ones could be added anywhere, in any file, at any time. sealed closes that set to the current file, giving the compiler the complete picture it needs to verify every possibility is handled.


Whatโ€™s Next

Pattern matching, combined with sealed traits, is Scalaโ€™s most distinctive tool for writing correct, exhaustively-checked branching logic. Next: For-Comprehensions in Scala, which builds directly on the flatMap/map chaining from the Option/Either guide, expressed as cleaner, more readable syntax sugar.