Skip to content
AgileLogics
6 min read

Pattern Matching in Depth

Prerequisites: Enums and algebraic data types

Pattern matching can take apart almost any structure.

Nested patterns

case class Address(city: String, country: String)
case class User(name: String, age: Int, address: Option[Address])

def describe(user: User): String = user match
  case User(name, _, Some(Address(_, "UK")))  => s"$name lives in the UK"
  case User(name, age, None) if age < 18      => s"$name is a minor with no address"
  case User(name, _, Some(Address(city, _)))  => s"$name lives in $city"
  case User(name, _, None)                    => s"$name has no address"

@main def nested(): Unit =
  println(describe(User("Ada", 36, Some(Address("London", "UK")))))
  println(describe(User("Linus", 28, Some(Address("Helsinki", "FI")))))
  println(describe(User("Sam", 15, None)))
  println(describe(User("Grace", 45, None)))
Ada lives in the UK
Linus lives in Helsinki
Sam is a minor with no address
Grace has no address

Lists and sequences

def summarize(xs: List[Int]): String = xs match
  case Nil                => "empty"
  case List(x)            => s"one element: $x"
  case List(a, b)         => s"two elements: $a and $b"
  case first :: rest      => s"starts with $first, then ${rest.length} more"

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

@main def lists(): Unit =
  println(summarize(Nil))
  println(summarize(List(7)))
  println(summarize(List(1, 2)))
  println(summarize(List(1, 2, 3, 4)))
  println(sum(List(1, 2, 3, 4)))
empty
one element: 7
two elements: 1 and 2
starts with 1, then 3 more
10

head :: tail splits a list into its first element and the rest — the classic pattern for recursive list processing.

Binding, alternatives, and types

def classify(x: Any): String = x match
  case n: Int if n < 0          => "negative int"
  case 0 | 1 | 2                => "small int"
  case s: String if s.isEmpty   => "empty string"
  case list @ List(_, _, _*)    => s"list of ${list.length}"
  case (a: Int, b: Int)         => s"int pair summing to ${a + b}"
  case _                        => "other"

@main def binding(): Unit =
  List(-5, 1, "", List(1, 2, 3), (2, 3), 3.5).map(classify).foreach(println)
negative int
small int
empty string
list of 3
int pair summing to 5
other
  • list @ List(...) binds the whole matched value to a name.
  • _* matches any number of remaining elements.

Patterns outside match

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

@main def elsewhere(): Unit =
  val Point(x, y) = Point(3, 4)
  println(x + y)

  val points = List(Point(1, 2), Point(3, 4))
  for Point(a, b) <- points do println(a * b)

  val parsed = List("1", "x", "3").collect { case s if s.toIntOption.isDefined => s.toInt }
  println(parsed)
7
2
12
List(1, 3)

collect takes a partial function — a set of case clauses — and keeps (and transforms) only the elements that match.