Pattern Matching on Sealed Traits
Learn to use pattern matching on sealed traits in Scala. This lesson covers sealed traits, exhaustive matching, and how they work for Python developers.
Focus: pattern matching on sealed traits
You've used match statements in Python to branch on values, but you've probably felt the pain: a typo in a key, an unhandled case slipping through, and no compiler to catch it until runtime. In Scala, pattern matching on sealed traits solves this by turning exhaustive, type-safe case analysis into a first-class language feature. This lesson shows you how to model data with sealed traits and match on them so the compiler guarantees every possible case is handled — something Python's if/elif chains can never promise.
The problem this lesson solves
In Python, when you have a value that can be one of several types or shapes, you typically write an if/elif chain. Consider a simple data model for a payment method:
class CreditCard:
def __init__(self, number, expiry):
self.number = number
self.expiry = expiry
class PayPal:
def __init__(self, email):
self.email = email
def describe(payment):
if isinstance(payment, CreditCard):
return f"Card ending in {payment.number[-4:]}"
elif isinstance(payment, PayPal):
return f"PayPal account {payment.email}"
else:
raise ValueError("Unknown payment method")
This works, but it's fragile. If you add a BankTransfer class next week, you must remember to update describe. Forget it, and your code silently raises a runtime error — or worse, falls through to a default branch with wrong behavior. You don't know you've missed a case until a user hits it in production.
Scala's answer is sealed traits combined with pattern matching. A trait is like an abstract base class; adding sealed restricts all implementations to the same file. This lets the compiler enumerate every possible subtype at compile time. When you match on a sealed trait, the compiler can check if your match is exhaustive — if you forgot BankTransfer, your code won't compile. That's the pain point this lesson removes.
Core concept / mental model
Think of a sealed trait as a closed family. The sealed keyword says: "These are the only possible subtypes, and they all live in this file." It's like a Python Union type, but enforced by the compiler with exhaustive checking.
Pattern matching is Scala's switch on steroids. Instead of matching on integer constants, you match on the shape of a value. When combined with sealed traits, it becomes a powerful tool for algebraic data types — a way to model "this or that" alternatives.
Here's a mental model: imagine a sealed trait as a parent class with a fixed set of children. You write a function that takes an instance of the parent. Pattern matching decomposes the instance into its concrete child and extracts its fields, all in one syntactic construct.
In Python, you'd write isinstance and then access attributes. In Scala, the pattern itself does both, with compile-time guarantees.
How it works step by step
- Define a sealed trait — use
sealed trait PaymentMethod. Thesealedmodifier must be in the same file as all subtypes. - Add case classes for each subtype —
case class CreditCard(number: String, expiry: String) extends PaymentMethod. Each case class gets a constructor, getters, and a copy method automatically. - Write a match expression —
payment match { case CreditCard(n, _) => ... }. The patternCreditCard(n, _)simultaneously checks the type and binds thenumberfield ton. - Ensure exhaustiveness — the compiler will warn (or error) if you've missed a subtype. This is the killer feature.
- Refactor with confidence — add a new case class, and the compiler tells you exactly which
matchexpressions need a new case.
Let's see it in action with a concrete example.
Hands-on walkthrough
We'll build a payment description function, mirroring the Python version from earlier.
// Payment.scala
sealed trait PaymentMethod
case class CreditCard(number: String, expiry: String) extends PaymentMethod
case class PayPal(email: String) extends PaymentMethod
def describe(payment: PaymentMethod): String = payment match {
case CreditCard(number, _) => s"Card ending in ${number.takeRight(4)}"
case PayPal(email) => s"PayPal account $email"
}
Compile this with scalac Payment.scala. No error — the match is exhaustive because PaymentMethod only has two subtypes. Now, add a third subtype:
case class BankTransfer(accountNumber: String) extends PaymentMethod
Recompile. You'll get a warning:
warning: match may not be exhaustive.
It would fail on the following input: BankTransfer(_)
The compiler caught the missing case. Add it:
def describe(payment: PaymentMethod): String = payment match {
case CreditCard(number, _) => s"Card ending in ${number.takeRight(4)}"
case PayPal(email) => s"PayPal account $email"
case BankTransfer(account) => s"Bank transfer to account $account"
}
Now the match is exhaustive again.
Let's try a more feature-rich example: a Shape hierarchy with computing area.
sealed trait Shape
case class Circle(radius: Double) extends Shape
case class Rectangle(width: Double, height: Double) extends Shape
case class Triangle(base: Double, height: Double) extends Shape
def area(shape: Shape): Double = shape match {
case Circle(r) => math.Pi * r * r
case Rectangle(w, h) => w * h
case Triangle(b, h) => 0.5 * b * h
}
println(area(Circle(1.0))) // 3.141592653589793
println(area(Rectangle(2, 3))) // 6.0
Notice how each pattern binds the extracted fields directly. No explicit type checks, no casting.
For a more advanced pattern, you can guard matches:
def categorize(payment: PaymentMethod): String = payment match {
case CreditCard(number, _) if number.length == 16 => "Standard card"
case CreditCard(_, _) => "Non-standard card"
case PayPal(email) if email.endsWith("@gmail.com") => "Gmail PayPal"
case PayPal(_) => "Other PayPal"
}
Guards (if) add extra conditions after the pattern, letting you handle edge cases within a subtype.
Compare options / when to choose what
When your data has a fixed set of alternatives, sealed traits with pattern matching are the idiomatic Scala choice. But there are alternatives.
| Approach | Pros | Cons | Best for |
|---|---|---|---|
| Sealed trait + pattern match | Compile-time exhaustiveness, type safety, concise | Requires defining a trait hierarchy | Modeling closed sets of alternatives (ADTs) |
| Plain trait (unsealed) | Open for extension by any subclass | No exhaustiveness checking | When you need open polymorphism and separate compilation |
match on case classes (without sealed) |
Exhaustive on a finite set, but not enforced | No compiler warning for new subclasses | Quick one-off scripts |
Python if/elif / match |
Familiar to Python devs | No compile-time guarantees, error-prone | Porting legacy Python code |
For most use cases, sealed traits are the way to go when you control all variants. If you're designing a library that third-party code must extend, an unsealed trait might be better — but you lose exhaustiveness. That's a deliberate trade-off.
Troubleshooting & edge cases
- "not found: type CreditCard" — Make sure all case classes are in the same file as the sealed trait. Sealed traits cannot be extended from another file.
- "match may not be exhaustive" warning — This is a warning, not an error, unless you enable
-Xfatal-warnings. Treat it as an error in CI. Add the missing case. - Pattern order matters — Scala evaluates patterns top to bottom. Put more specific patterns (with guards) first.
- Unused variable in pattern — Use
_to ignore fields you don't need. If you name a variable starting with lowercase and don't use it, you get an "unused" warning. - Mistake:
case PayPal(_)is fine, butcase PayPal(e)whereeis unused triggers a warning. Prefer_. - Pattern matching on
null— Sealed traits are objects; you can passnull. A match onnullwill throwMatchError. PreferOptionover null. case _ =>— A wildcard case catches everything. Use it sparingly; it defeats exhaustiveness checking.
What you learned & what's next
You've learned how pattern matching on sealed traits is Scala's safe, expressive replacement for Python's isinstance chains. You can define a sealed trait, add case classes, and write exhaustive match expressions that the compiler verifies. You now know how to apply this in a hands-on exercise, and you've seen how guards and wildcards refine matches.
Next in the track, you'll build on this foundation to combine sealed traits with recursive data structures — think linked lists and expression trees — where pattern matching truly shines. But first, why not apply what you've learned to a small exercise of your own?
Practice recap
Try converting a Python function that uses isinstance to a sealed trait in Scala. Define a sealed trait Vehicle, add case classes Car and Bicycle, and write a describe function using pattern matching. Add a third subtype, Motorcycle, and confirm the compiler warns about the missing case.
Common mistakes
- Forgetting to put all subtype case classes in the same file as the sealed trait, causing a compile error.
- Ignoring the 'match may not be exhaustive' warning, which leads to runtime MatchError when a new subtype is added.
- Using
case _ =>as a catch-all, which suppresses exhaustiveness checking and lets bugs slip through. - Writing patterns in the wrong order, so a broad pattern shadows more specific ones that use guards.
Variations
- Use pattern guards (
if) to add fine-grained conditions inside a case. - Bind pattern results to variables and use them in the right-hand side expression.
- Combine sealed traits with recursive data structures for expressive tree parsing.
Real-world use cases
- Modeling payment methods (credit card, PayPal, bank transfer) in a checkout system with compile-time exhaustive handling.
- Parsing and evaluating arithmetic expression trees in a calculator or compiler where each node type is a sealed subtype.
- Handling user commands in a CLI tool where each command is a case class and pattern matching dispatches actions.
Key takeaways
- Sealed traits restrict all subtypes to the same file, enabling compile-time exhaustiveness checking.
- Pattern matching on sealed traits combines type check and field extraction in one concise syntax.
- The compiler warns about non-exhaustive matches, turning a runtime bug into a compile-time fix.
- Geographic order of patterns matters — put guarded, specific patterns first.
- Sealed traits + pattern matching are ideal for modeling closed sets of alternatives (ADTs), replacing Python's if/elif chains.
Keep learning
Related tutorials, quizzes, and articles for this topic.
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.