Pattern Matching with match and if let

Pattern Matching with match and if let

The feature that makes enums worth having: match arms that check a value and pull its data out in the same breath, the exhaustiveness check that catches forgotten cases at compile time, and the shorthands — if let and let-else — that keep simple checks simple.

13 min read4 learning objectives

What You'll Learn

  • Read and write multi-arm match expressions, including arms that bind data as they match
  • Explain why match has to be exhaustive — and watch the compiler enforce it
  • Use catch-alls, or-patterns, ranges, and match guards to keep arms short and clear
  • Choose confidently between match, if let, and let-else for a given situation

Last lesson ended with a promise: pattern matching is what makes an enum worth having in the first place. Time to deliver — and if there's one feature that, more than almost anything else, makes people fall a little in love with how Rust is designed, it's this one.

match: the switch statement that finished the job

You've brushed up against match twice already — a quick preview in control flow, then again putting Message to work last lesson. Time to slow all the way down and see exactly what's happening:

Read it as a list of arms: a pattern, a fat arrow, and the value to produce if that pattern matches — each arm separated by a comma. match checks them top to bottom, runs the first one that fits, and — because match is an expression, exactly the same way if is — the whole thing evaluates to whatever that arm produced. No break, no fall-through, no quietly running two arms because you forgot one.

The real superpower: a pattern can pull data out while it matches

Here's where match stops being "a nicer switch" and becomes something genuinely different. A pattern doesn't just check which variant you're holding — it can, in the very same motion, bind whatever data that variant is carrying to a name you can use immediately:

Remember that Shape enum from last lesson's exercise? This is the payoff. Shape::Circle(radius) does two jobs in one breath: it confirms "this value is a Circle," and it binds whatever f64 lives inside to the name radius — ready to use immediately, on the very same line, like any other variable. Same story for Rectangle(width, height) and Triangle(base, height). One pattern, one arm: the check and the extraction, together, with nothing left to wire up by hand.

Matching Option<T>: the bread-and-butter case

This combination — match the variant, grab what it's carrying — comes up so often with Option<T> specifically that you'll write code shaped exactly like this, constantly, almost from this point forward:

None => None and Some(i) => Some(i + 1): handle the empty case explicitly, and handle the present case by unwrapping it, transforming it, and wrapping the result back up. This shape — "do nothing to nothing, do something useful to something" — is close to the most common pattern you'll write once Option and Result start showing up in your own code. Which is to say: almost immediately, and then constantly after that.

Forget a case, and the compiler simply refuses to let it slide

What happens if you accidentally drop an arm?

That's match's defining trait, and arguably its best one: it's exhaustive. Every possible variant of the type you're matching on must be accounted for — by name, or by catch-all — or your program does not compile, full stop. Compare that to a typical switch in most other languages, where forgetting a case simply means... nothing happens, silently, and you find out three weeks later when a real user manages to hit exactly the path you forgot. Rust would much rather have this conversation with you right now, on your laptop, while the stakes are exactly zero.

Catch-alls, or-patterns, ranges, and guards

You won't always want — or need — to spell out every single value by hand. Rust's pattern syntax packs in a small toolbox of shortcuts for "and everything else":

Walk through it top to bottom:

  • 1 | 2 | 3 matches any of three exact values — read | as "or."
  • 4..=9 matches an inclusive range: anything from 4 through 9, both ends included.
  • other if other < 0 is a match guard — it binds the value to other, the same way you just saw with Shape, and then tacks on an extra condition that must also hold for this arm to fire.
  • _, the wildcard, matches absolutely anything without even bothering to name it — it's how you tell the compiler "yes, I genuinely do mean every other case, on purpose, I haven't forgotten anything."

if let: for when match feels like ceremony

Sometimes you only care about a single specific pattern, and writing out a whole match — plus a do-nothing arm just to satisfy exhaustiveness — starts to feel like noise for noise's sake:

if let is sugar for exactly that shape: "if this value matches this one pattern, run this code — otherwise, don't." You're trading away exhaustiveness checking in exchange for brevity, which is a perfectly reasonable trade the moment you genuinely don't care what the other cases are. And yes — it pairs with else too, working exactly like the if/else you already know from control flow.

One more shape worth recognizing: let...else

There's a newer, related piece of syntax you'll start noticing in real codebases: let...else. It binds a pattern for the "happy path" and forces you to handle — and exit — the unhappy path immediately, right at the point where the binding happens:

Read let Some(value) = maybe_number else { ... } as: "if this matches, bind value and keep going; if it doesn't, run this block instead" — and that block is required to diverge (return, break, continue, or panic!), because the rest of the function genuinely cannot proceed without value. The win is readability: the "happy path" of the function stays flat, instead of nesting one level deeper inside an if let. Once you start reading real-world Rust, you'll see this shape constantly.

Quick exercise

Write a function fn quadrant(x: i32, y: i32) -> String that returns "origin" if both coordinates are zero, "axis" if exactly one of them is, and otherwise one of "first", "second", "third", or "fourth" describing which quadrant the point falls in. Here's a hint that's also a small preview of something neat: you're allowed to match on a tuple directly — match (x, y) { ... } — and patterns can nest inside the parentheses exactly the way you'd hope.

You can now read — and write — the construct that shows up in nearly every nontrivial function anyone has ever written in this language. Combine it with what you already know about enums, and you're ready for the lesson where these two ideas team up to replace exception handling almost entirely: Option, Result, and a future with no null in it.