Scala Case Classes and Pattern Matching Complete Guide

TT

Scala Case Classes and Pattern Matching Complete Guide

Case classes and pattern matching are among Scala's most celebrated features. Together they make domain modeling concise and exhaustive pattern matching safe. This module covers both in depth.

Case Classes: The Basics

A case class is a regular class with a set of auto-generated features:

scala
case class Person(name: String, age: Int)

val alice = Person("Alice", 30)
val bob = Person("Bob", 25)

println(alice.name)    // Alice
println(alice)         // Person(Alice,30)  — toString is auto-generated
println(alice == bob)  // false
println(alice == Person("Alice", 30))  // true  — equals compares fields

Case classes automatically get:

  • toString — readable representation
  • equals and hashCode — structural equality based on fields
  • copy — create modified copies
  • apply — constructor without new
  • unapply — enables pattern matching

The copy Method

scala
val alice = Person("Alice", 30)
val olderAlice = alice.copy(age = 31)
println(olderAlice)  // Person(Alice,31)

copy creates a new instance with the same field values, allowing you to override specific fields. Case classes are immutable by default — you can't modify them, you create new ones.

Pattern Matching with match

Pattern matching is Scala's powerful alternative to switch:

scala
def describe(x: Any): String = x match {
  case 0          => "zero"
  case 1          => "one"
  case n: Int     => s"other int: $n"
  case s: String  => s"string: $s"
  case _          => "something else"
}

println(describe(0))      // zero
println(describe(42))     // other int: 42
println(describe("hi"))   // string: hi

The _ wildcard matches anything and is the default case.

Destructuring Case Classes

Pattern matching can destructure case class instances:

scala
case class Point(x: Double, y: Double)
case class Line(start: Point, end: Point)

val line = Line(Point(0, 0), Point(3, 4))

line match {
  case Line(Point(0, 0), end) =>
    println(s"Line from origin to $end")
  case Line(start, end) =>
    println(s"Line from $start to $end")
}
// Line from origin to Point(3.0,4.0)

Destructuring works recursively — you can match nested structures in one expression.

Guard Clauses

Add conditions to patterns with if:

scala
def classify(n: Int): String = n match {
  case x if x < 0   => "negative"
  case 0             => "zero"
  case x if x % 2 == 0 => s"positive even: $x"
  case x             => s"positive odd: $x"
}

println(classify(-5))  // negative
println(classify(0))   // zero
println(classify(4))   // positive even: 4
println(classify(7))   // positive odd: 7

Sealed Traits and ADTs

Sealed traits combined with case classes create algebraic data types (ADTs) — a fundamental functional programming pattern:

scala
sealed trait Result[+A]
case class Success[A](value: A) extends Result[A]
case class Failure(error: String) extends Result[Nothing]

def divide(a: Int, b: Int): Result[Double] =
  if (b == 0) Failure("Division by zero")
  else Success(a.toDouble / b)

val result = divide(10, 2)
result match {
  case Success(value)  => println(s"Result: $value")
  case Failure(error)  => println(s"Error: $error")
}
// Result: 5.0

The sealed keyword means all subtypes must be in the same file. The compiler can then verify that pattern matching is exhaustive — it warns if you miss a case.

Matching Collections

Pattern matching works on lists and other collections:

scala
def sumList(list: List[Int]): Int = list match {
  case Nil          => 0
  case head :: tail => head + sumList(tail)
}

println(sumList(List(1, 2, 3, 4, 5)))  // 15

Nil matches an empty list. head :: tail deconstructs a list into its first element and the rest.

The unapply Extractor Pattern

Custom extractors implement unapply to enable pattern matching on any type:

scala
object PositiveInt {
  def unapply(n: Int): Option[Int] =
    if (n > 0) Some(n) else None
}

object EvenInt {
  def unapply(n: Int): Option[Int] =
    if (n % 2 == 0) Some(n) else None
}

def describe(n: Int): String = n match {
  case PositiveInt(x) if EvenInt.unapply(x).isDefined => s"positive even: $x"
  case PositiveInt(x) => s"positive: $x"
  case 0 => "zero"
  case x => s"negative: $x"
}

Matching Tuples

Tuples can be pattern matched directly:

scala
val coords = (3, -4)

coords match {
  case (0, 0) => println("Origin")
  case (x, 0) => println(s"On x-axis at $x")
  case (0, y) => println(s"On y-axis at $y")
  case (x, y) => println(s"Point at ($x, $y)")
}
// Point at (3, -4)

Frequently Asked Questions

Q: Why are case classes immutable by default? Immutability makes code easier to reason about — a case class value is just data. It also makes case classes safe to use as Map keys and in Sets (since they can't change, their hashCode won't change either). When you need to "modify" a case class, you use copy to create a new instance, which is the functional programming approach.

Q: What happens if I forget a case in a sealed trait match? If you use a sealed trait and miss a case in your match expression, the Scala compiler gives a warning: "match may not be exhaustive". This is a compile-time safety net — it doesn't crash at runtime, but the missing case will throw a MatchError. Using sealed traits and enabling -Xfatal-warnings in SBT turns these into hard compile errors, which is a good practice.

Q: Can case classes extend other case classes? No — Scala does not allow case-to-case inheritance. A case class cannot extend another case class. The reason is that the auto-generated equals and hashCode methods would be inconsistent with inheritance. Instead, use a sealed trait as the base and make each variant a separate case class.


Part of Scala Mastery Course — Module 8 of 22.