Scala Pattern Matching and Algebraic Data Types
Edited by Dev Kilaru, Senior Data Analyst. How we make our content
Introduction
Pattern matching is where Scala's design pays off. You describe a closed set of possibilities with an enum or a sealed trait, and then use match to handle each case. The compiler checks that you covered all of them, so adding a new case later points you to every place that must change. This combination, called algebraic data types, is a reliable way to model things like payments, commands, results, and parsed expressions.
Prerequisites
- Case classes and traits from the previous lesson
Step-by-Step Guide
Step 1: Match on values, types, and structure
A match expression tries each case from the top and uses the first that fits. Patterns can be constants, variables that bind a value, alternatives joined with a vertical bar, and typed patterns that check the runtime type. A guard adds an if condition to a case for finer control. The underscore matches anything and is usually last as a catch-all. Because match is an expression, the whole thing produces a value you can assign or return.
def describe(x: Any): String = x match
case 0 => "zero"
case n: Int if n < 0 => "negative integer"
case n: Int => s"integer $n"
case s: String => s"text of length ${s.length}"
case _ => "something else"
println(describe(-4)) // negative integer
println(describe("scala")) // text of length 5Step 2: Define a closed set of cases with an enum
A Scala 3 enum lists all the allowed variants of a type. Each variant can carry its own data, and in that form the enum is an algebraic data type. A shape is either a circle with a radius or a rectangle with width and height, and nothing else. Because the set is closed, the compiler knows every possibility. Simple enums without data also work, and they come with helpful methods such as values and valueOf.
enum Shape:
case Circle(radius: Double)
case Rectangle(width: Double, height: Double)
enum Level:
case Low, Medium, High
println(Level.values.toList) // List(Low, Medium, High)Step 3: Take data apart and get exhaustiveness checking
Matching on an enum or sealed type destructures each case, binding its fields to names. The compiler then checks that the match is exhaustive. If you later add a Triangle to Shape and forget to update this function, you get a compile-time warning pointing at the match instead of a runtime failure in production. That is a big benefit over a chain of instance checks. Avoid a catch-all case when matching on your own closed types, because it silences exactly this check.
import Shape.*
def area(shape: Shape): Double = shape match
case Circle(r) => math.Pi * r * r
case Rectangle(w, h) => w * h
println(area(Rectangle(3, 4))) // 12.0
println(area(Circle(1))) // 3.141592653589793Step 4: Use sealed traits and case classes when you need more control
An enum covers most cases, but a sealed trait with case classes is the more flexible form and the one you will see in existing code. Sealed means all subtypes must be in the same file, so the compiler still knows the full set. Case objects are good for variants without data. This version can mix in other traits, extend other classes, and have richer members, which makes it a good fit for larger models such as an order lifecycle.
sealed trait OrderStatus
case object Pending extends OrderStatus
case class Shipped(trackingId: String) extends OrderStatus
case class Cancelled(reason: String) extends OrderStatus
def message(status: OrderStatus): String = status match
case Pending => "Waiting to ship"
case Shipped(id) => s"On its way, tracking $id"
case Cancelled(reason) => s"Cancelled: $reason"Step 5: Match on lists, tuples, and nested structures
Patterns can be nested to any depth. A list matches the pattern head :: tail, which splits off the first element, with Nil for the empty list, and this is the classic way to write a recursive function over a list. Tuples match by position. You can also match on the contents of a case class inside another structure, binding only the parts you need and ignoring the rest with underscores. The result is code that reads like a description of the shape of the data.
def sum(xs: List[Int]): Int = xs match
case Nil => 0
case head :: tail => head + sum(tail)
println(sum(List(1, 2, 3, 4))) // 10
val point = (3, 0)
val where = point match
case (0, 0) => "origin"
case (_, 0) => "on the x axis"
case (0, _) => "on the y axis"
case _ => "elsewhere"Step 6: Match on Option and use it in assignments
Option has two cases, Some with a value and None, so it is itself an algebraic data type, and matching on it is the most explicit way to handle a missing value. You can also destructure directly in a val definition or a for loop, which is handy for tuples and case classes. When a pattern in an assignment might not match, use a match expression or a for comprehension instead, so a mismatch does not become a runtime error.
def describeAge(age: Option[Int]): String = age match
case Some(n) if n >= 18 => "adult"
case Some(_) => "minor"
case None => "unknown"
val (name, score) = ("Asha", 91)
case class Point(x: Int, y: Int)
val Point(px, py) = Point(2, 5)FAQs
- What does 'exhaustive' mean in pattern matching?
- It means the match handles every possible case of the type. For enums and sealed types the compiler can check this and warns when a case is missing, which turns many would-be runtime errors into compile-time messages.
- Should I choose enum or sealed trait?
- Use an enum for a simple set of variants, which is the shortest form in Scala 3. Choose a sealed trait with case classes when you need the extra flexibility, or when you are reading or maintaining Scala 2 code, where enums with data are not available.
- Is pattern matching the same as a switch statement?
- It does the job of a switch, but goes much further: it can check types, destructure data, bind variables, apply guards, and nest, and it returns a value.
Summary
Enums and sealed traits describe a closed set of possibilities, and match takes them apart while the compiler checks you handled all of them. With guards, nested patterns, and list patterns you can write short, safe functions over rich data. The next lesson applies the same idea to the problem of missing values and failures, using Option, Either, and Try.
Related tutorials
- Schedule and Debug Shell Scripts: cron, bash -x, and Portability
- Shell Scripting Project: A Backup Script with Rotation and a Log Report
- Writing Safe Bash Scripts: set -euo pipefail, getopts, and trap
- Shell Pipes, Redirection, grep, sed, and awk for Text Processing
- Bash Loops, Arrays, and Functions Explained
Comments
Log in or create a free account to join the discussion.