Skip to content
← All insights
Scala14 min read

Pattern matching in Scala: more than a switch statement

How Scala patterns deconstruct algebraic data types, collections and extractors; where exhaustivity helps; and how guards, partial functions and overly broad matches create risk.

A pattern tests a shape and binds its parts

Scala match evaluates cases from top to bottom. A case can test a constant, type or constructor shape and bind names to values inside that shape. The right-hand side runs for the first matching case.

Case-class patterns use the companion extractor generated for the class. Matching is therefore about structure rather than manually calling fields and checking tags. This is especially useful with sealed traits and case classes representing a closed domain.

A match should normally produce a value. Assigning the result to an explicit type helps the compiler reveal branches that accidentally return Unit, a broad Any or inconsistent effect types.

Deconstruct a closed domain valuescala 2.13
sealed trait PaymentResult
object PaymentResult {
  final case class Accepted(reference: String) extends PaymentResult
  final case class Declined(reason: DeclineReason) extends PaymentResult
  case object TimedOut extends PaymentResult
}

def message(result: PaymentResult): String = result match {
  case PaymentResult.Accepted(reference) => s"accepted: $reference"
  case PaymentResult.Declined(reason)     => s"declined: ${reason.code}"
  case PaymentResult.TimedOut             => "timed out"
}

Sealed hierarchies make exhaustivity useful

When every direct subtype is known to the compiler, it can warn that a match omits a possible case. This turns adding a new domain state into a list of decisions that may need updating.

A wildcard case suppresses that help. Use it when unknown input is genuinely part of the contract, not to silence a warning on a closed model. Matching a broad parent type from another module may remain non-exhaustive because new implementations can appear.

Compiler warnings should be visible in CI. An exhaustivity warning that scrolls past in a local build is not protecting a production decision.

Let a new case expose missing decisionsscala 2.13
sealed trait AccountStatus
object AccountStatus {
  case object Pending extends AccountStatus
  case object Active extends AccountStatus
  case object Closed extends AccountStatus
}

def canWithdraw(status: AccountStatus): Boolean = status match {
  case AccountStatus.Pending => false
  case AccountStatus.Active  => true
  case AccountStatus.Closed  => false
}

// Adding Suspended asks this function for an explicit decision.

Patterns work naturally with Option, Either and collections

Matching Some and None is useful when each branch contains different behaviour. For simple transformations, Option.fold, map or getOrElse can be shorter and preserve the optional structure more clearly.

List patterns expose empty, head and tail cases. Exact-length patterns are readable for small protocol-like inputs, while recursive head :: tail processing suits structural algorithms. Remember that Seq extractors may inspect the collection to determine its shape.

Either matching is clearest at boundaries where Left and Right become different HTTP responses or commands. Within a fail-fast workflow, map, flatMap and a for-comprehension usually avoid repeatedly unpacking the same context.

Match where branches make different decisionsscala 2.13 / Play
def render(result: Either[FindError, Account]): Result = result match {
  case Right(account) => Ok(Json.toJson(AccountResponse.from(account)))
  case Left(FindError.NotFound(id)) =>
    NotFound(Json.obj("accountId" -> id.value))
  case Left(FindError.AccessDenied) => Forbidden
}

def describe(values: List[String]): String = values match {
  case Nil          => "empty"
  case only :: Nil  => s"one value: $only"
  case head :: tail => s"first: $head, plus ${tail.size}"
}

Guards refine a matched shape

A guard adds a Boolean condition after a pattern. Cases are still tried in order, so an earlier broad case can make a guarded case unreachable. Keep more specific patterns before general ones.

Guards are appropriate for conditions that do not define a new structural type. If the same validation appears throughout the application, a refined domain value or distinct case may make the constraint visible before matching.

Avoid guards with side effects or expensive calls. Their evaluation is part of case selection and may be repeated as the match evolves, which makes control flow difficult to see and test.

Use guards for local value conditionsscala 2.13
def band(balance: BigDecimal): BalanceBand = balance match {
  case value if value < 0    => BalanceBand.Overdrawn
  case value if value == 0   => BalanceBand.Empty
  case value if value < 1000 => BalanceBand.Standard
  case _                     => BalanceBand.High
}

Type patterns cannot recover erased type arguments

On the JVM, generic type arguments are normally erased. A pattern such as case values: List[String] cannot reliably distinguish List[String] from List[Int] at runtime, and the compiler warns that the type test is unchecked.

Match on a non-erased outer type, preserve evidence such as a ClassTag when the use case genuinely supports it, or redesign the boundary so the variant is represented by a sealed type. Inspecting the first element is not a safe substitute because the collection may be empty or heterogeneous through unsafe input.

Type patterns are also a design signal. Repeatedly matching concrete implementations can mean a missing method on a sealed domain model or an algebra that has become too broad.

Represent variants explicitly instead of matching erased typesscala 2.13
sealed trait FieldValues
object FieldValues {
  final case class Text(values: List[String]) extends FieldValues
  final case class Numbers(values: List[Int]) extends FieldValues
}

def count(values: FieldValues): Int = values match {
  case FieldValues.Text(items)    => items.size
  case FieldValues.Numbers(items) => items.size
}

// Avoid: case strings: List[String] => ...

Extractors define what a pattern exposes

An object with unapply can provide a pattern without exposing a type’s representation. Extractors are useful for validated identifiers and stable views over a value, but complex extractors can hide parsing, allocation or failure behind innocent-looking syntax.

Keep unapply pure and inexpensive. If validation needs a detailed error, a constructor returning Either is more informative than an extractor that can only decline the match with None.

PartialFunction uses the same pattern vocabulary for behaviour defined only on some inputs. Methods such as collect and collectFirst apply partial functions safely; calling a partial function directly on an unsupported input throws MatchError unless isDefinedAt is checked.

Use collect for a partial transformationscala 2.13
val closedIds: List[AccountId] = events.collect {
  case AccountClosed(id, _) => id
}

val renderKnown: PartialFunction[DomainEvent, String] = {
  case AccountOpened(id, _) => s"opened ${id.value}"
  case AccountClosed(id, _) => s"closed ${id.value}"
}

// collect checks where the partial function is defined.

Keep matches close to the decision they own

Pattern matching is strongest when one component owns a complete decision over a closed model: a domain transition, JSON presenter or error-to-HTTP mapping. Scattered matches on the same hierarchy can make every new case require edits across unrelated modules.

Moving all behaviour onto methods is not automatically better. A presenter often should own how every error becomes an HTTP response, while the error types should remain independent of Play. Choose the side whose responsibility the decision represents.

Review for exhaustivity, order, wildcard use and erased type tests. Then ask whether the match is exposing a meaningful domain choice or compensating for a value whose type no longer describes its possible states.

  • Is the matched hierarchy closed and exhaustively handled?
  • Does a wildcard hide a future domain decision?
  • Are specific cases placed before broader patterns?
  • Would map, fold or collect express the operation more directly?
  • Is a guard repeating validation that belongs in a domain type?
  • Does the component performing the match own the decision?