Skip to content

9. Pattern Matching

The previous chapter built enums but could only peek inside them with a match we hadn't properly explained. Time to fix that. Pattern matching is how you inspect a value, figure out which shape it has, and pull out the data it carries — all at once, and in a way the compiler can check for completeness. If you've used Rust's match or a switch expression in modern Java, you'll recognise the idea; if you've only had Python's if/elif ladders or match statements, this is the same idea with teeth: the compiler won't let you forget a case.

Matching and destructuring

A match expression takes a value and lists patterns, each with an arrow -> and a body. Sushi tries each pattern in turn and runs the body of the first that fits. When a variant carries data, the pattern can destructure it: name the data, and it becomes a variable inside that branch.

enum Shape:
    Circle(f64)
    Rectangle(f64, f64)

fn describe(Shape s) ~:
    match s:
        Shape.Circle(r) ->
            println("Circle with radius {r}")
        Shape.Rectangle(w, h) ->
            println("Rectangle {w} by {h}")
    return Result.Ok(~)

fn main() i32:
    describe(Shape.Circle(2.0))
    describe(Shape.Rectangle(3.0, 4.0))
    return Result.Ok(0)

Output:

Circle with radius 2
Rectangle 3 by 4

In Shape.Circle(r) ->, the r isn't a value to compare against — it's a name that captures whatever radius this circle carries. Inside that branch, r is just a f64 you can use. Likewise Rectangle(w, h) binds both pieces at once. This is the everyday rhythm of working with enums: match to find the variant, destructure to get the data.

match is the way in

Destructuring through match is the only way to read an enum variant's associated data. There's no shape.radius field to reach for — the data lives inside a particular variant, and matching is how you prove you're looking at the right one before you touch it.

The wildcard _

Sometimes you only care about one or two variants and want a single catch-all for the rest. The wildcard pattern _ matches anything.

enum Status:
    Idle()
    Traveling(string)
    Panicking(i32)
    Lost()

fn report(Status s) ~:
    match s:
        Status.Traveling(dest) ->
            println("On the way to {dest}")
        # The bare _ is a catch-all: it handles every remaining variant.
        _ ->
            println("Not currently traveling")
    return Result.Ok(~)

fn main() i32:
    report(Status.Traveling("Magrathea"))
    report(Status.Idle())
    report(Status.Panicking(11))
    report(Status.Lost())
    return Result.Ok(0)

Output:

On the way to Magrathea
Not currently traveling
Not currently traveling
Not currently traveling

Here only Traveling gets special treatment; Idle, Panicking, and Lost all fall through to _. The wildcard also works inside a variant to ignore data you don't need: Status.Panicking(_) matches any panic level without binding it. Use _ to keep a match focused on the cases that actually matter.

Nested patterns

Patterns can reach more than one level deep. Because Result and the enums it wraps are themselves enums, you can match a Result.Err(...) and the specific error variant inside it in a single pattern.

enum DriveError:
    NotConfigured()
    ImprobabilityTooLow()

fn engage_drive(i32 improbability) string | DriveError:
    if (improbability == 0):
        return Result.Err(DriveError.NotConfigured())
    if (improbability < 100):
        return Result.Err(DriveError.ImprobabilityTooLow())
    return Result.Ok("We have arrived... somewhere.")

fn try_drive(i32 improbability) ~:
    # The pattern reaches inside Result.Err to the specific DriveError variant.
    match engage_drive(improbability):
        Result.Ok(message) ->
            println("Success: {message}")
        Result.Err(DriveError.NotConfigured()) ->
            println("Failed: the drive is not configured.")
        Result.Err(DriveError.ImprobabilityTooLow()) ->
            println("Failed: improbability is too low.")
    return Result.Ok(~)

fn main() i32:
    try_drive(0)
    try_drive(42)
    try_drive(9999)
    return Result.Ok(0)

Output:

Failed: the drive is not configured.
Failed: improbability is too low.
Success: We have arrived... somewhere.

Look at Result.Err(DriveError.NotConfigured()) ->. That single pattern says "this is an Err, and the error inside it is specifically NotConfigured". You handle each failure mode distinctly without unwrapping the Result first and matching again. Nested patterns keep error handling flat and readable, even when the data is several layers deep.

Exhaustiveness: the compiler has your back

Here's the feature that makes pattern matching more than a fancy switch. When you match on an enum, Sushi requires you to handle every variant (or cover the leftovers with a _). Forget one, and the program won't compile.

That's not a nuisance — it's a safety net. Suppose you later add a fourth variant to an enum. Every match that doesn't account for it suddenly fails to compile, pointing you at exactly the code that needs updating. Whole categories of "oops, I forgot the new case" bugs simply can't reach a running program. It's the same instinct behind Result itself: make the compiler force you to deal with every possibility, so your users never trip over the one you missed.

Two ways to be exhaustive

You can list every variant explicitly, or list the ones you care about and finish with _ as a catch-all. Both satisfy the exhaustiveness check. Prefer listing variants explicitly when you genuinely want different behaviour for each — that way, adding a new variant later forces you to revisit the match instead of silently sliding into the _ branch.

What you learned

  • match selects a branch by the shape of a value and destructures variant data into named variables (Shape.Circle(r) -> ...).
  • The wildcard _ is a catch-all, both as a whole-pattern fallback and inside a variant to ignore data.
  • Patterns nest: Result.Err(DriveError.NotConfigured()) matches the outer and inner variants together.
  • Matching on an enum is exhaustive — the compiler insists every variant is handled, turning forgotten cases into compile errors instead of runtime bugs.

Next we'll write our own generic types and functions. On to Generics.