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

Sponsored

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

  1. Define a sealed trait — use sealed trait PaymentMethod. The sealed modifier must be in the same file as all subtypes.
  2. 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.
  3. Write a match expressionpayment match { case CreditCard(n, _) => ... }. The pattern CreditCard(n, _) simultaneously checks the type and binds the number field to n.
  4. Ensure exhaustiveness — the compiler will warn (or error) if you've missed a subtype. This is the killer feature.
  5. Refactor with confidence — add a new case class, and the compiler tells you exactly which match expressions 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, but case PayPal(e) where e is unused triggers a warning. Prefer _.
  • Pattern matching on null — Sealed traits are objects; you can pass null. A match on null will throw MatchError. Prefer Option over 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

  1. Use pattern guards (if) to add fine-grained conditions inside a case.
  2. Bind pattern results to variables and use them in the right-hand side expression.
  3. 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.

Sponsored

Sponsored

Discussion

Questions, corrections, and tips help everyone reading this page.

0 comments

Add a comment

Shown publicly with your comment.

Be constructive · max 4,000 characters

No comments yet — start the thread.

Related tutorials, quizzes, and articles for this topic.