Skip to content

← Errors Are Values step 2 of 24

Easy Primitives

Matching on Option

Write two functions that take Option<i32> apart by pattern matching.

pub fn classify(items: Vec<Option<i32>>) -> Vec<String>
pub fn first_pair(a: Option<i32>, b: Option<i32>) -> Option<i32>

classify labels each element: "none" for None, "zero" for Some(0), "neg:{n}" for a negative value, "pos:{n}" for a positive one — so [Some(1), Some(-2), Some(0), None] becomes ["pos:1", "neg:-2", "zero", "none"].

first_pair returns a‘s value, but only when both a and b carry one. If either is None the answer is None. Note what this is not: it is not a.or(b), and first_pair(None, Some(3)) is None, not Some(3).

The starter does not compile — that is the lesson

Run it before you change anything. You will get E0004:

error[E0004]: non-exhaustive patterns: `None` not covered

Option<i32> is an ordinary enum with two variants, and a match in Rust is an expression that must produce a value on every path. The compiler counts the variants you covered and stops you when one is missing. Guards do not count — rustc deliberately does not reason about whether n > 0 and n < 0 together cover the integers, so Some(n) if n > 0 plus Some(n) if n < 0 still needs a guardless Some arm underneath.

This is the single most useful property of enums in Rust: add a variant later, and every match that has not been updated becomes a compile error. There is no equivalent for null. A language where absence is a value of the same type gives you no place to hang that check.

The related error, E0005, appears when you use a refutable pattern where an irrefutable one is required — let Some(n) = maybe; on its own is E0005, “refutable pattern in local binding”. The fix is one of the forms below.

The four forms, and when each one reads best

// 1. match — the general form. Every arm visible, exhaustiveness checked.
match maybe {
    Some(n) => println!("got {n}"),
    None => println!("nothing"),
}

// 2. if let — one interesting arm, one boring one.
if let Some(n) = maybe {
    println!("got {n}");
}

// 3. let-else — bind, or leave. The binding outlives the block.
let Some(n) = maybe else {
    return "nothing".to_string();
};
println!("got {n}");   // `n` is in scope here, unindented

// 4. while let — loop until the pattern stops matching.
while let Some(top) = stack.pop() {
    println!("{top}");
}

let-else (stable since 1.65) is the one beginners meet last and reach for most once they know it. It is the fix for the rightward-drift you get from nesting if let blocks: handle the failure and leave, then carry on at the original indentation with the value bound.

Clippy has opinions about which form to use — single_match will tell you a two-arm match with a () arm should be an if let, and redundant_pattern_matching will tell you if let Some(_) = x should be x.is_some(). These are translations between the four forms, so reading clippy’s suggestion is how you learn the local convention.

A trap worth knowing now

while let Some(Ok(x)) = iter.next() { /* ... */ }

This silently stops at the first Err. The pattern Some(Ok(x)) fails to match Some(Err(e)) exactly the way it fails to match None, and while let cannot tell “the iterator ended” from “an item did not match”. Errors vanish without a trace. This is the sort of bug that survives code review, so it is worth recognising the shape on sight.

Notes

  • Match arms are tried top to bottom, so Some(0) must come before a catch-all Some(n) or it will never be reached.
  • Matching on a tuple of two Options — match (a, b) { (Some(x), Some(_)) => …, _ => … } — is often clearer than nesting one match inside another, and it is how first_pair wants to be written.
  • {n} inside format! is inline captured-identifier syntax; it is the idiomatic form and clippy’s uninlined_format_args pushes you towards it.

Remember the grade is compile + tests + clippy -D warnings.

Loading visualization…