Skip to content

match — handling every case

The Rust question for this lesson: you have a value that’s one of several kinds — how do you run different code depending on which kind it is, pull out the data each kind carries, and have the compiler stop you if you forget a kind?

In lesson 1 we built types that are “exactly one of a fixed set” (Shape, and clinker’s Value). Defining them is half the story; the other half is taking them apart safely. Rust’s tool for that is match, and its superpower is exhaustiveness — it won’t compile until you’ve accounted for every possibility.

Back to our Shape from lesson 1. Suppose we want each shape’s area. The kinds need different formulas, so we match on the shape and write one arm per kind:

fn area(shape: &Shape) -> f64 {
match shape {
Shape::Point => 0.0,
Shape::Circle(r) => PI * r * r,
Shape::Rectangle(w, h) => w * h,
}
}

Three things are happening at once:

  • One arm per kind. pattern => expression. The first pattern that fits wins.
  • Binding. In Shape::Circle(r), the name r binds to the radius this circle carries — you’ve destructured the payload out and can use it on the right of =>. Rectangle(w, h) binds both numbers.
  • match is an expression. The whole thing evaluates to a value, which is why area can return it directly — no return needed.

Run it, then try the experiment in the comment:

rust // editable

That refusal is the feature. A match on an enum must cover every variant; leave one out and the code does not compile. Compare that to a switch in many languages, where forgetting a case just silently does nothing.

// quick check

In the arm Shape::Circle(r) => PI * r * r, what is r?

When you only care about one case, a full match is overkill. Rust gives you:

// if let — act on one variant, ignore the rest
if let Shape::Circle(r) = shape {
println!("a circle of radius {r}");
}
// matches! — a quick boolean "is it this kind?"
let is_circle = matches!(shape, Shape::Circle(_));

The _ in Circle(_) means “some payload, but I don’t need to name it.” Hold onto that _: on a full match it becomes a catch-all arm — convenient, but it quietly switches off the exhaustiveness check, so use it sparingly on enums you might grow.

Clinker matches on Value constantly — that’s the whole point of having modelled the cell as an enum. Here’s a real one: turning a Value into the short type name CXL uses in its messages and typechecker.

clinker-record ·value.rs ·type_name fn @47d2e12
/// Returns the CXL type name as a static string.
pub fn type_name(&self) -> &'static str {
match self {
Value::Null => "null",
Value::Bool(_) => "bool",
Value::Integer(_) => "int",
Value::Float(_) => "float",
Value::String(_) => "string",
Value::Date(_) => "date",
Value::DateTime(_) => "datetime",
Value::Array(_) => "array",
Value::Map(_) => "map",
}
}

Nine variants, nine arms — exhaustive. Notice it uses _ to ignore each payload (Bool(_)), because all it needs is the kind, not the data. And right below it in the same file, clinker uses the matches! shorthand for a one-kind test:

clinker-record ·value.rs ·is_null fn @47d2e12
pub fn is_null(&self) -> bool {
matches!(self, Value::Null)
}

// quick check

Why does type_name spell out all nine arms instead of a single catch-all (a _ => … arm)?

Inside clinker: exhaustiveness as a to-do list

Section titled “Inside clinker: exhaustiveness as a to-do list”

Remember the closed Value enum from lesson 1? match is what cashes in that guarantee. The day a new Value variant is added, every exhaustive match over Valuetype_name, the serializer, the format writers, the CXL evaluator — stops compiling until it handles the new kind. The compiler hands the author a precise to-do list of every place that must change. In an engine with many consumers of Value, that’s the difference between “the compiler told me the nine spots to update” and “we shipped a bug that mishandled the new kind in one forgotten branch.”

A read-only exercise — no repo files are edited; use the playground.

Add a Shape::Triangle(f64, f64) variant (base, height) to the playground enum. Before adding its area arm, run the code and read the error. Then add the arm (0.5 * base * height) and confirm it compiles. In one sentence: what did the compiler do for you between those two runs?

💡 Hint 1

Adding a variant changes the set of kinds. An exhaustive match over that set is now missing one — what does Rust say, and at what moment (compile or run)?

Show solution

Before the new arm, compilation fails with non-exhaustive patterns: Triangle(..) not covered — at compile time, before the program ever runs. The compiler enforced that every kind of Shape is handled, so a forgotten case can’t slip through to a user. That’s the same mechanism that protects every match over clinker’s Value.

  • match is just a switch.” No — there’s no fall-through, it must be exhaustive, and arms bind the payload so you can use the inner data.
  • “A _ => catch-all is harmless.” On an enum it silences the exhaustiveness check: add a variant later and the catch-all swallows it instead of the compiler flagging the spots that need attention. Prefer spelling out variants on types you may grow — which is exactly why type_name does.

match handles every kind a value is. But often the real question is whether a value exists at all — a lookup that might find nothing, a field that might be absent. Rust models “maybe nothing” as just another enum you already know how to match on: Option, lesson 3.