Pattern Matching — Scala's Swiss Army Knife | Scala Programming Guide

- Published on

Matching on Constants, Types, and Tuples
Pattern matching is expression-based programming—it's how you decompose and handle different cases. Rather than writing imperative code that checks condition A, then B, then C, pattern matching lets you declare all the cases you care about and let the compiler match input against them. The power comes from the fact that patterns can be literals (matching exact values), types (is this an Int or a String?), or complex structures (does this tuple contain specific values?). Each case can extract and bind values, giving you a clean, declarative way to branch on complex data shapes. Pattern matching is the most Scala-like way to write branching logic, and mastering it is key to writing idiomatic Scala.
// Matching on constants
def analyzeInput(input: Any): String = input match {
case 0 => "Zero is neutral"
case 1 => "One is unity"
case 2 => "Two is a pair"
case true => "Boolean true"
case false => "Boolean false"
case "quit" => "Exiting"
case _ => "Something else"
}
println(analyzeInput(0))
println(analyzeInput("quit"))
println(analyzeInput("other"))
// Matching on types
def describeType(value: Any): String = value match {
case i: Int => s"An integer: $i"
case s: String => s"A string: '$s'"
case d: Double => s"A decimal: $d"
case b: Boolean => s"A boolean: $b"
case list: List[_] => s"A list with ${list.length} elements"
case _ => "Unknown type"
}
println(describeType(42))
println(describeType("hello"))
println(describeType(List(1, 2, 3)))
// Matching on tuples (destructuring)
def describePair(pair: (String, Int)): String = pair match {
case ("age", age) if age < 18 => s"Minor: $age years old"
case ("age", age) => s"Adult: $age years old"
case (label, value) => s"$label = $value"
}
println(describePair(("age", 16)))
println(describePair(("count", 42)))
These basic patterns form the foundation. Matching on constants is straightforward: if input equals this constant, execute this case. Matching on types uses the pattern: Type syntax to extract and cast in one step. Tuples are destructured automatically, binding each component to a variable. This is far cleaner than checking conditions with if/else, and it's exhaustive when combined with sealed types.
Matching on Case Classes (Deep Destructuring)
Case classes are pattern matching's ideal partner. Their structure is automatically decomposable. Because case classes are designed with pattern matching in mind, matching against them feels natural and powerful. You can destructure deeply into nested structures—pulling out the first item in a list within an order, checking its product ID, all in a single pattern. This is far cleaner than extracting fields one by one with imperative code. The combination of case classes and pattern matching is one of Scala's greatest strengths for working with data structures. You can express complex domain logic as pattern matches that are both readable and type-safe.
// A domain model for an e-commerce order
sealed trait OrderStatus
case object PendingApproval extends OrderStatus
case object Processing extends OrderStatus
case object Shipped extends OrderStatus
case object Delivered extends OrderStatus
case object Cancelled extends OrderStatus
case class LineItem(productId: String, quantity: Int, unitPrice: Double) {
def total: Double = quantity * unitPrice
}
case class Order(
orderId: String,
customer: String,
items: List[LineItem],
status: OrderStatus
)
// Destructure deep into case class structure
def describeOrder(order: Order): String = order match {
// Match on the status
case Order(id, customer, _, PendingApproval) =>
s"Order $id for $customer is awaiting approval"
// Match on items list structure
case Order(id, customer, items, Processing) if items.isEmpty =>
s"Order $id is processing but has no items (suspicious!)"
// Destructure the first item
case Order(id, customer, LineItem(productId, qty, price) :: rest, Delivered) =>
s"Order $id delivered to $customer. First item: $qty x $productId @ \$${price}"
// Match any delivered order with multiple items
case Order(id, customer, _ :: _ :: _, Delivered) =>
s"Order $id to $customer delivered with multiple items"
// Cancelled orders with reason (if we add that)
case Order(id, _, _, Cancelled) =>
s"Order $id was cancelled"
// Catch-all for any other scenario
case order =>
s"Order ${order.orderId}: status ${order.status}"
}
val order1 = Order("O1", "Alice", List(LineItem("P1", 1, 29.99)), PendingApproval)
val order2 = Order("O2", "Bob", List(
LineItem("P2", 2, 15.99),
LineItem("P3", 1, 49.99)
), Delivered)
println(describeOrder(order1))
println(describeOrder(order2))
Notice how much information is extracted and checked in a single pattern. The pattern Order(id, customer, LineItem(productId, qty, price) :: rest, Delivered) simultaneously checks: is this an Order? Is it Delivered? Does it have at least one item (the :: pattern)? If all match, it extracts orderId, customer, first item details, and the remaining items. This level of structural decomposition in a single expression is what makes pattern matching so powerful. In imperative code, you'd write a series of if statements and assignments. In pattern matching, you declare the shape you expect and extract everything at once.
Guards in Patterns
Guards add conditional logic to pattern matches using if. Sometimes a pattern alone isn't enough to distinguish between cases. Maybe you want to match all integers, but only handle the even ones differently from odd ones. Or you want to match all recipes, but treat expensive ones differently from cheap ones. Guards let you add extra conditions: the pattern matches structurally, but then the guard's condition must also be true for that case to succeed. If it's false, Scala moves on to the next case. This lets you write more specific branching without nesting multiple pattern matches. Guards are essential for expressing business logic that depends on values, not just structure.
// A recipe system with ingredients and requirements
case class Recipe(name: String, ingredients: List[String], servings: Int) {
def cost: Double = ingredients.length * 2.5
def difficulty: String = {
if (ingredients.length > 10) "Hard"
else if (ingredients.length > 5) "Medium"
else "Easy"
}
}
def suggestRecipe(recipe: Recipe): String = recipe match {
// Expensive, hard recipes
case Recipe(name, ing, serv) if recipe.cost > 20 && recipe.difficulty == "Hard" =>
s"$name is a challenging, expensive recipe ($${recipe.cost})"
// Quick recipes (few ingredients)
case Recipe(name, ing, _) if ing.length <= 3 =>
s"$name is quick—just ${ing.length} ingredients!"
// Large batch recipes
case Recipe(name, _, serv) if serv >= 8 =>
s"$name feeds $serv people—great for parties"
// Budget recipes
case Recipe(name, _, _) if recipe.cost < 5 =>
s"$name is budget-friendly at $${recipe.cost}"
// Default
case Recipe(name, _, _) =>
s"$name is a standard recipe"
}
val pasta = Recipe("Pasta Carbonara", List("pasta", "eggs", "bacon", "cheese"), 4)
val stew = Recipe("Beef Stew", List(
"beef", "potatoes", "carrots", "onions", "garlic", "tomato", "herbs",
"stock", "wine", "flour", "salt", "pepper"
), 8)
println(suggestRecipe(pasta))
println(suggestRecipe(stew))
Guards transform pattern matching from purely structural matching into value-based branching. Each case now says "match this structure and this condition." This is far cleaner than checking conditions inside the case body, because the guard integrates the condition into the case selection itself.
Variable Binding with @
The @ operator binds a matched pattern to a variable, letting you reference the entire match. Sometimes you destructure a value to access its parts, but you also need the whole thing. For example, you might extract the email address from an EmailNotification to log which recipient was notified, but you also need access to the entire EmailNotification object for further processing. Rather than extracting it twice (once destructured, once whole), use @ to bind the whole match to a variable while still destructuring parts of it. This is a small feature, but it eliminates a common annoyance when working with complex patterns.
// A notification dispatching system
sealed trait Notification
case class EmailNotification(to: String, subject: String, body: String) extends Notification
case class SMSNotification(phoneNumber: String, message: String) extends Notification
case class SlackNotification(channel: String, message: String) extends Notification
def processNotification(notif: Notification): Unit = notif match {
// Bind the entire EmailNotification to 'email'
case email @ EmailNotification(to, _, _) =>
println(s"Processing email to $to")
println(s"Full email object: $email")
// Bind the message within SMSNotification
case SMSNotification(phone, msg @ _) =>
println(s"SMS to $phone: ${msg.length} characters")
// Bind a sub-pattern
case notif @ SlackNotification(channel, _) =>
println(s"Slack in $channel: ${notif.message}")
}
val email = EmailNotification("alice@example.com", "Hello", "Check this out!")
val sms = SMSNotification("+1234567890", "Hi there!")
processNotification(email)
processNotification(sms)
The @ operator is particularly useful when you want to pass the entire matched object to a function or store it for later use. Without @, you'd have to reconstruct the object from its parts or match separately.
Matching Sequences and Lists
Scala provides powerful syntax for matching list structures. Lists and sequences are fundamental data structures, and you often need to match on their shape: is it empty? Does it have exactly one element? Two or more? What's the first element, and what's the rest? Scala's pattern syntax lets you express all these cases elegantly using the :: (cons) operator and * for variable-length tails. This is far cleaner than checking isEmpty() and head/tail methods in imperative code. List pattern matching is essential for recursive algorithms and for extracting structure from collections.
// A task scheduler that processes command lists
case class Task(id: String, action: String)
def processTaskList(tasks: List[Task]): Unit = tasks match {
// Empty list
case List() =>
println("No tasks to process")
// Single task
case List(task) =>
println(s"Processing single task: ${task.action}")
// Two tasks
case List(first, second) =>
println(s"Processing ${first.action}, then ${second.action}")
// First and rest (head :: tail pattern)
case head :: tail =>
println(s"First task: ${head.action}")
println(s"Remaining: ${tail.length} tasks")
// Match arbitrary counts with *
case first :: second :: rest =>
println(s"First two: ${first.action}, ${second.action}")
println(s"Plus ${rest.length} more")
}
processTaskList(List())
processTaskList(List(Task("1", "Write docs")))
processTaskList(List(
Task("1", "Write docs"),
Task("2", "Review code"),
Task("3", "Deploy")
))
// Variable-length sequences with * (binding multiple elements)
def analyzeScores(scores: Seq[Int]): String = scores match {
case Seq() => "No scores"
case Seq(perfect) if perfect == 100 => "Perfect score!"
case Seq(a, b, c) => s"Three scores: $a, $b, $c"
case Seq(first, rest @ _*) =>
s"First: $first, ${rest.length} more scores"
}
println(analyzeScores(Seq()))
println(analyzeScores(Seq(100)))
println(analyzeScores(Seq(85, 90, 95)))
The :: operator is essential for recursive patterns: head :: tail matches any non-empty list, binding the first element to head and the rest to tail. The _* wildcard matches a variable number of remaining elements. These patterns are the foundation for writing recursive algorithms that process lists naturally.
Extractors: unapply and unapplySeq
Extractors let you create custom patterns. They're the inverse of constructors. When you write a case class, the compiler automatically creates an unapply method that lets you destructure it in pattern matches. But what if you want to match on something that's not a case class—like a plain string that should be parsed as an email? You can define your own unapply method that says "given a string, try to extract the local and domain parts." Then you can use that custom extractor in patterns, extending pattern matching to types that weren't originally designed for it. This is a powerful metaprogramming technique for building domain-specific pattern matching. Extractors let you define the logic for "what does it mean to decompose this type" independently of the type itself, enabling safe, declarative parsing.
// Extract data by pattern
case class User(name: String, email: String)
object UserEmail {
// Extractor: decompose email into local and domain parts
def unapply(email: String): Option[(String, String)] = {
val parts = email.split("@")
if (parts.length == 2) Some((parts(0), parts(1)))
else None
}
}
// Use the extractor in pattern matching
def analyzeEmail(email: String): String = email match {
case UserEmail(local, "gmail.com") => s"Gmail user: $local"
case UserEmail(local, "example.com") => s"Internal user: $local"
case UserEmail(local, domain) => s"User $local at $domain"
case _ => "Invalid email"
}
println(analyzeEmail("alice@gmail.com"))
println(analyzeEmail("bob@example.com"))
println(analyzeEmail("not-an-email"))
// unapplySeq for variable-length extraction
object WordList {
def unapplySeq(phrase: String): Option[Seq[String]] = {
val words = phrase.trim.split("\\s+").toSeq
if (words.nonEmpty) Some(words) else None
}
}
def analyzePhrase(phrase: String): String = phrase match {
case WordList(first) => s"One word: $first"
case WordList(first, second) => s"Two words: $first, $second"
case WordList(first, middle @ _*) =>
s"Multi-word: starts with $first, ${middle.length} more words"
}
println(analyzePhrase("Hello"))
println(analyzePhrase("Hello World"))
println(analyzePhrase("Scala is awesome"))
Extractors are powerful for parsing and validation. An extractor returns Option[T], and if the extraction fails (returns None), the pattern doesn't match and you move to the next case. This makes extractors perfect for defining domain-specific "deconstructors" that safely parse complex types.
Sealed Trait Exhaustiveness Checking
When you have a sealed trait, Scala warns if your pattern match doesn't cover all cases. This is one of the most valuable safety features in Scala. Imagine you're building an HTTP response handler with cases for OK, NotFound, ServerError, etc. A sealed trait means the compiler knows all possible subtypes. If you write a pattern match but forget the NotFound case, the compiler warns you immediately, not after the code ships and a 404 is mishandled. This transforms pattern matching from a potentially fragile technique into a bulletproof way to handle finite sets of possibilities. The exhaustiveness check is the compiler enforcing correctness—it's saying "I know all the cases; you haven't covered them all," which is an invaluable safety guarantee.
// A sealed hierarchy ensures type safety
sealed trait HttpStatus {
def code: Int
def message: String
}
case object OK extends HttpStatus { def code = 200; def message = "OK" }
case object Created extends HttpStatus { def code = 201; def message = "Created" }
case object BadRequest extends HttpStatus { def code = 400; def message = "Bad Request" }
case object Unauthorized extends HttpStatus { def code = 401; def message = "Unauthorized" }
case object NotFound extends HttpStatus { def code = 404; def message = "Not Found" }
case object ServerError extends HttpStatus { def code = 500; def message = "Server Error" }
// Pattern match with exhaustiveness guarantee
def handleResponse(status: HttpStatus): String = status match {
case OK => "Success!"
case Created => "Resource created"
case BadRequest => "Invalid request"
case Unauthorized => "Please log in"
case NotFound => "Not found"
case ServerError => "Something went wrong"
// No default case needed—Scala won't compile if cases are missing
}
println(handleResponse(OK))
println(handleResponse(NotFound))
This exhaustiveness check is one reason sealed types are so central to Scala design. Combined with pattern matching, they create a compile-time guarantee: you've handled every case. If you later add a new case to HttpStatus, the compiler will complain about every pattern match that doesn't cover it. This forces you to update all the places that handle HttpStatus, preventing silent bugs.
Partial Functions and Pattern Matching
A partial function is defined for some inputs but not others. Pattern matching creates partial functions naturally. In normal pattern matching, you must handle all cases or provide a catch-all. But sometimes you want a function that deliberately doesn't handle everything—it handles only specific message types in a logging system, for instance. Partial functions let you express this explicitly. The runtime can check whether a partial function can handle a particular input before calling it. You can also compose partial functions with orElse to build pipelines where each handler tries in turn. Partial functions are essential for event-handling systems and middleware architectures where different handlers specialize in different message types.
// A logging router that handles different message types
sealed trait LogMessage
case class ErrorLog(code: Int, message: String) extends LogMessage
case class WarningLog(message: String) extends LogMessage
case class InfoLog(message: String) extends LogMessage
case class DebugLog(details: String) extends LogMessage
// This partial function only handles ErrorLog and WarningLog
val criticalHandler: PartialFunction[LogMessage, Unit] = {
case ErrorLog(code, msg) =>
println(s"CRITICAL ERROR [$code]: $msg")
case WarningLog(msg) =>
println(s"WARNING: $msg")
}
// Check if a message can be handled
val msg: LogMessage = InfoLog("Starting service")
if (criticalHandler.isDefinedAt(msg)) {
criticalHandler(msg)
} else {
println("Not a critical message")
}
// Compose partial functions
val infoHandler: PartialFunction[LogMessage, Unit] = {
case InfoLog(msg) => println(s"INFO: $msg")
}
val debugHandler: PartialFunction[LogMessage, Unit] = {
case DebugLog(details) => println(s"DEBUG: $details")
}
// Combine handlers with orElse (first succeeds, second is fallback)
val allHandlers = criticalHandler
.orElse(infoHandler)
.orElse(debugHandler)
allHandlers(ErrorLog(500, "Database crashed"))
allHandlers(InfoLog("All systems nominal"))
allHandlers(DebugLog("Processing request..."))
Partial functions are particularly valuable for building event-driven systems. Different handlers can specialize in different event types, and you compose them using orElse. If the first handler can't handle an event, it passes to the next. This creates a clean handler chain without explicit if/else branching.
Pattern Matching in for Comprehensions
for loops are syntactic sugar for map, flatMap, and filter. Pattern matching works inside! When you write a for loop over a collection, you can use patterns in the generator expression. Instead of just val item <- items, you can write Recipe(name, tags, _) <- recipes to destructure each recipe as you iterate. If a pattern fails to match, that item is silently skipped (like filtering). This makes for comprehensions extremely expressive: you can iterate, pattern match, and filter all in one readable construct. for loops with pattern matching are one of the most elegant ways to express complex data transformations.
// A recipe recommendation system
case class Recipe(name: String, tags: List[String], complexity: Int)
val recipes = List(
Recipe("Pasta", List("italian", "quick"), 2),
Recipe("Curry", List("indian", "spicy"), 4),
Recipe("Salad", List("healthy", "quick"), 1),
Recipe("Steak", List("meat", "fancy"), 3)
)
// Find recipes with specific tags using pattern matching in for
def findRecipes(recipes: List[Recipe], tag: String): List[String] = {
for {
Recipe(name, tags, _) <- recipes // Pattern match each recipe
if tags.contains(tag) // Filter by tag
} yield name
}
println(findRecipes(recipes, "quick"))
// More complex example: extract pairs
val pairs = List(
(1, "one"),
(2, "two"),
(3, "three"),
(4, "four")
)
val result = for {
(num, word) <- pairs // Destructure tuple
if num % 2 == 0 // Only even numbers
} yield s"$num -> $word"
println(result)
// Nested patterns in for
val nestedData = List(
("users", List("alice", "bob")),
("admins", List("charlie")),
("guests", List())
)
val allPeople = for {
(role, people) <- nestedData
person <- people // Inner iteration
if !person.isEmpty
} yield s"$person ($role)"
println(allPeople)
The beauty of pattern matching in for comprehensions is that failed matches are silently skipped. If a recipe doesn't match the pattern (which won't happen with a List[Recipe], but might if you were parsing), it's simply not included in the result. This implicit filtering is far cleaner than explicit filter() calls.