Skip to content

Reading a Value — exhaustive match

Every time the engine acts on a cell (coercing it, comparing it, formatting it for an error) it first has to ask which of the nine shapes is this Value? and branch on the answer. Do that in dozens of places across the engine and one question becomes load-bearing: when a tenth shape is added, what stops a single one of those branches from silently going stale? You met the closed Value enum in the Value cell as “one of nine shapes, fixed at compile time.” A closed set is only half the deal; the other half is the tool that takes a Value apart, the Rust match, and its defining property: the compiler refuses to let you forget a case. We use just enough of match to read the engine’s real dispatch code, and The Rust Book, ch. 6 is the canonical treatment if you want pattern matching from first principles.

  • Read a match over Value and name what each arm does, including what (_) and a named binding capture.
  • Distinguish an exhaustive match from one that leaks a case, and predict which one the compiler rejects.
  • Write a match that handles every variant of a small closed enum, scaffolded down to an unaided task.
  • Explain why exhaustiveness turns “add a tenth shape” from a hunt-for-every-site nightmare into a compiler-guided chore, and why a _ => catch-all forfeits that.

New terms in this lesson (each is also expanded inline at the point you first need it):

  • match: pick a branch by which variant the value is.
  • arm: one pattern => expression branch of a match.
  • exhaustiveness: the rule that every variant must be covered, enforced at compile time.
  • binding: naming a variant’s payload so the arm can use it; (_) matches it but keeps nothing.

The engine constantly needs to ask a Value “what shape are you?”, whether for diagnostics, coercion, or error messages. Value::type_name answers it with one match over all nine variants:

clinker-record ·value.rs ·type_name fn @19acdcb4
pub fn type_name(&self) -> &'static str {
match self {
Value::Null => "null",
Value::Bool(_) => "bool",
Value::Integer(_) => "int",
Value::Float(_) => "float",
Value::String(_) => "string",
// ... one arm per variant, all nine
}
}

Each line is one arm: a pattern on the left of =>, the value to return on the right. Value::Null matches the unit variant. Value::Bool(_) matches the Bool variant, and its (_) says “I don’t care about the payload, just the shape”: it’s a binding that captures nothing. If this function needed the inner number instead, it would write Value::Integer(n) => /* use n */ and the n would be the i64 itself.

A match over a closed enum must cover every variant: leave one out and the code won’t compile. That sounds like nagging until you imagine the alternative. Value is dispatched on across the whole engine: coercion, comparison, serialization, formatting. Now suppose a tenth shape is added. With an exhaustive match, the compiler immediately lists every single site that must be updated. Miss none, ship nothing half-handled. Without it, you’d be grepping and praying.

This exhaustiveness is the deep reason the closed enum from the last lesson pays off: closed set + exhaustive match = the compiler is a complete, always-current checklist of “have you handled every case?” (It’s also why reaching for a _ => catch-all is a smell in this codebase: it silences exactly the warning you want.)

rust // editable

It compiles and prints three type names. Now delete the Value::Float arm and Run. The compiler stops you with an error, non-exhaustive patterns: &Value::Float(_) not covered, naming the exact case you dropped. That is the prediction from the top, on a value you can run.

You’ve read the real match and watched it refuse an incomplete one. Now you write the machinery, scaffolded down to an unaided task. Each rung adds exactly one idea.

Here is a small, self-contained match that mirrors type_name’s structure, but one arm goes further and binds its payload to use it. Run it as given. Null and Bool(_) match the shape and ignore the payload; Integer(n) and Text(s) bind the inner value so the body can read it.

rust // editable

Below, the Value enum has a Float variant, but type_name is missing the arm that handles it. Three arms are written for you. Add the one Value::Float(_) arm. Until you do, the compiler refuses to build with a non-exhaustive patterns error pointing at exactly the gap.

rust // editable
💡 Hint 1
The arm has the same shape as the others: Value::Float(_) => "float",. The (_) matches the f64 payload without binding it, because type_name only needs the shape. Resist adding a _ => ... catch-all; spell the variant out so a future tenth shape still breaks the build.
Show solution
fn type_name(v: &Value) -> &'static str {
match v {
Value::Null => "null",
Value::Integer(_) => "int",
Value::Float(_) => "float",
}
}

With every variant spelled out, adding a fourth variant to Value would break this build at exactly this match, which is the behavior you want. A _ => "unknown" catch-all would have compiled today and silently mishandled that future variant. The exhaustive match is what guarantees you considered all the cases; the catch-all is what throws that guarantee away.

Now with much less scaffolding. Write numeric_or_zero, which returns the inner number for the numeric variants and 0 for everything else. The rule: Integer(n) returns n; Float(f) returns f rounded to an i64 with f as i64; Null and Bool both return 0. You must bind the payload in the numeric arms (Integer(n), Float(f)) to return it. Spell out every variant, no catch-all.

rust // editable
Show solution
fn numeric_or_zero(v: &Value) -> i64 {
match v {
Value::Integer(n) => *n, // bind the i64 and return it
Value::Float(f) => *f as i64, // bind the f64, round toward zero
Value::Null => 0,
Value::Bool(_) => 0,
}
}

The numeric arms bind their payload (n, f) so the body can return it; the others match the shape and return a constant. Every variant is listed, so the match is exhaustive: add a fifth variant and this function stops compiling until you decide what it returns, instead of quietly falling through. (You write *n because v is a &Value, so the bindings are references; the next lesson on ownership makes that * precise.)

Why-bridge: exhaustiveness as a refactoring tool

Section titled “Why-bridge: exhaustiveness as a refactoring tool”

Why does the engine lean so hard on exhaustive match instead of a convenient _ => fallthrough? Because the compiler errors are the feature. When Value gains a variant (say the team adds a Decimal cell type) the build doesn’t compile until every match over Value has an arm for it. Each error is a precise to-do item: this coercion, that formatter, this comparison, all flagged, none missed. The change becomes a guided chore instead of a grep-and-pray hunt across the whole engine.

A _ => catch-all breaks that. It compiles today and absorbs tomorrow’s new variant silently: the new shape falls into the catch-all and gets mishandled, with no error to warn you. So in this codebase a catch-all over Value is a smell: it trades a loud compile-time checklist for a quiet runtime bug.

You can now take a Value apart safely: read a match, bind the payloads you need, and rely on exhaustiveness as a compiler-checked to-do list. Next: how the engine passes records around without copying them, the ownership rules that make it fast.

Go deeper on the Rust (optional, one-directional; the same match machinery taught from first principles, no engine framing required):

Glossary terms used: match, arm, exhaustiveness, binding.