Crabrix blog

Rust pattern matching: handle missing values without panics

Use match, if let and let else to handle optional values, validate variants, borrow fields and keep failure paths visible in practical Rust code.

Start where a value might be absent

A sensor reading can be present, missing, zero or below the accepted range. A project search can find a record or return nothing. Treating these situations as if they always contain a usable value makes a program fragile. Rust expresses the alternatives through types such as Option and Result, then lets pattern matching describe what should happen in each case. The useful habit is to inspect the alternatives before extracting the data.

This guide follows a small classification function and extends its reasoning to parsing, borrowing and collections. The examples are standard-library programs that you can run in Crabrix. Their purpose is practical: make absence ordinary, preserve the information needed for a diagnostic, and avoid a panic where the application has a reasonable recovery path. We will also distinguish compiler-checked coverage from the product decisions that only you can specify.

Four distinct capsules pass through an inspection instrument and travel along separate tracks to matching destinations.
Inspect the alternative first, then choose the operation that belongs to that case.

Make the alternatives visible in one match

Consider an Option<i32>. It has two structural variants, Some and None, but the integer inside Some can still have several meanings. Our classifier calls zero “Zero,” positive integers “Positive,” negative integers “Other integer,” and absence “Missing.” These labels are a deliberately small domain policy. They are not built-in meanings of Option. A different application could reject negative readings or treat zero as missing, but it should state that policy explicitly.

The function below matches the exact zero case first, then uses a guard for positive values, and handles the remaining integer and missing cases. A match selects the first applicable arm. Changing the order can change the result: putting Some(_) first would catch every present value before the specific arms could apply. Keep the most informative distinctions visible, and write assertions for values that could otherwise fall into an overly broad catch-all.

fn classify(reading: Option<i32>) -> &'static str {
    match reading {
        Some(0) => "Zero",
        Some(value) if value > 0 => "Positive",
        Some(_) => "Other integer",
        None => "Missing",
    }
}
fn main() {
    assert_eq!(classify(Some(0)), "Zero");
    assert_eq!(classify(Some(5)), "Positive");
    assert_eq!(classify(Some(-2)), "Other integer");
    assert_eq!(classify(None), "Missing");
    println!("Four classification checks passed");
}

Coverage and correctness are different checks

Rust checks that a match covers the possibilities required by its input type. That catches a missing None arm when no other arm handles it. A guard is a runtime condition, however, so a guarded arm does not by itself establish that every value of a structural variant is covered. Our unguarded Some(_) arm handles the remaining integers. This makes both the structural coverage and the runtime range policy clear.

An exhaustive match can still implement the wrong rule. It might label every negative number as positive or confuse an absent reading with zero. The compiler cannot infer the product’s intended meaning from those English labels. Use small independently specified examples alongside exhaustiveness. The infographic shows four observed paths through our classifier; it does not imply that Option<i32> contains only four possible values. There are many integers, and these cases represent the boundaries between outcomes.

Some(0) maps to Zero, Some(5) to Positive, Some(-2) to Other integer, and None to Missing.
The examples exercise four outcomes. The final unguarded present-value arm covers integers that fail earlier conditions.

Sources: The Rust Book: match control flow

Use if let when one alternative matters

Sometimes only one variant needs work. An optional subtitle may be displayed when present and omitted otherwise. An if let Some(subtitle) = value expresses that focused decision without requiring a two-arm match. The omitted case still has a meaning: do nothing here. Before choosing this form, ask whether ignoring that case is intentional. A missing optional decoration and a failed payment are not equally suitable candidates for silent omission.

An else branch can provide a fallback when that makes the behavior clearer. If both branches do substantial work, a match often gives a more balanced presentation. The choice is about communication, not a rule that one syntax is always more idiomatic. Keep the reader’s question in view: do they need to understand one interesting case, or compare the outcomes of several alternatives? Pick the form that makes that comparison easiest without hiding an error.

Let else can keep the successful path straight

A let ... else statement is useful when a required pattern should either bind values for the rest of the scope or leave that path immediately. The else block must diverge, for example by returning or continuing. That requirement prevents execution from reaching later code with bindings that were never created. It often reduces indentation in a parser with several early rejection conditions.

The next example accepts a pair separated by one colon. Missing structure produces an error; a valid pair remains available after the statement. This is the same borrowing relationship a match would express, arranged so the successful path stays flat. The function permits empty sides because its purpose is structural splitting. If the domain forbids an empty key, add that separate validation rather than implying the delimiter operation already checked it.

fn pair(input: &str) -> Result<(&str, &str), &'static str> {
    let Some((key, value)) = input.split_once(':') else {
        return Err("expected key:value");
    };
    if key.is_empty() { return Err("empty key"); }
    Ok((key, value))
}
fn main() {
    assert_eq!(pair("city:Kyiv"), Ok(("city", "Kyiv")));
    assert_eq!(pair("missing"), Err("expected key:value"));
    assert_eq!(pair(":value"), Err("empty key"));
    println!("Pair checks passed");
}

Sources: The Rust Book: if let and let else

Match borrowed values when ownership should remain

Patterns can move values as well as inspect them. Matching an owned Option<String> and binding its string by value transfers that string into the arm. If later code still needs the original option, borrow it instead. Methods such as as_ref provide an option containing a shared reference, which is convenient when inspecting optional owned data without taking responsibility for its allocation.

This matters in real interfaces. A screen may need to inspect an optional error message while the model retains ownership for a later redraw. Moving the message merely to decide whether it should be displayed can create an unnecessary lifetime or ownership problem. Cloning the entire model might silence the compiler but obscure the original mistake. Begin by deciding whether the operation needs to read, mutate or consume the data, then choose a pattern and input expression that match that intention.

Do not replace expected absence with unwrap

unwrap is concise because it adopts a strong contract: the value must have the successful or present variant, otherwise execution panics. That can be suitable for a test fixture whose setup establishes the invariant. It is usually unsuitable for a user search, an optional configuration field or a parse result from external input. Those values are allowed to be absent or invalid by the nature of their source.

A default also needs a policy. unwrap_or(0) may be correct for a missing count that genuinely means zero; it can be misleading for a missing temperature or a failed calculation. Pattern matching lets you keep those distinctions visible. If a fallback requires expensive work, consider a lazy fallback operation rather than constructing it unconditionally. Readability improves when the selected fallback is justified by the domain rather than added solely to remove an Option from a type signature.

Keep Result errors separate from ordinary absence

An Option says that a value may be absent. A Result carries a success value or an error explaining why an operation failed. Turning every parse error into None can erase information needed to repair the input. Conversely, inventing an error for a perfectly ordinary missing optional field can make callers handle noise. Choose the type that expresses the operation’s promise, then match on that type without weakening its information accidentally.

For example, finding the first nonempty line can reasonably return Option<&str>. Parsing that selected line as an integer can return a Result. A higher-level importer may need to distinguish an empty file from a malformed first record, so preserve both stages in its error model. Pattern syntax cannot decide which distinctions your application needs, but it makes those distinctions easy to inspect when the types retain them. Attach line or field context before discarding the structure that knows it.

Patterns also describe the shape of collections

A slice pattern can distinguish empty, single-item and multi-item input without indexing first and hoping the collection is long enough. This is useful for commands, small protocol frames and APIs whose behavior depends on a few leading elements. A rest pattern represents the remainder without allocating a new collection. It remains a view into the same source data, with the corresponding borrowing lifetime.

Keep structural checks separate from semantic validation. A two-element slice can have the right shape while containing an invalid command name or an out-of-range value. Also avoid writing a complicated nested pattern that takes longer to decode than an explicit sequence of checks. Patterns are a vocabulary for describing structure. Use that vocabulary where it removes an unsafe assumption or clarifies an alternative; keep names and intermediate steps where they make a business rule easier to follow.

A match can produce the value you need

A match is an expression, so its arms can produce a value for a surrounding binding or return. This is useful when several alternatives select one label, parsed value or domain outcome. The arm results must be compatible with the expression’s required type. A semicolon can turn a block’s final expression into a statement and change its result to the unit value, which explains some otherwise surprising type errors.

When that happens, inspect the result of each arm before adding a conversion. One arm might return a string slice while another accidentally returns nothing. Another might return an owned string when the intended API borrows a label. Naming the desired result type can make the diagnostic easier to follow and keeps the ownership decision visible.

Ignoring a field and binding it are different

The wildcard pattern _ ignores a value without giving it a local name. A name such as _message is still a binding even though its spelling suppresses an unused-variable warning. That distinction matters when a pattern can move an owned field. Do not rename a binding with an underscore and assume the ownership effect disappeared.

When only a variant’s presence matters, use a pattern that reflects that inspection and borrow the input if the owner must remain available. When the contents are needed, bind them deliberately and decide whether the operation should receive a reference or an owned value. Small experiments with an optional String help make this difference concrete: compile the inspection, then try to use the original owner afterward and explain the result.

Design tests around the boundaries between arms

Test a value in each intended outcome and values immediately around a numeric or structural boundary. Our classifier needs zero, a positive integer, a negative integer and absence. A parser with an empty-key rule needs a missing delimiter, an empty key and an ordinary valid pair. A borrowed inspection deserves a check that the owner is still available afterward. These cases verify different promises even if they use similarly small inputs.

When adding a new enum variant, review the wildcard arms as carefully as compiler errors. Explicit matches often force a useful decision, while a wildcard can quietly route the new variant into a generic fallback. Some fallbacks are intentional, especially at compatibility boundaries, but they should remain documented. A final review question is simple: after reading the match, can someone explain what happens when the expected value is missing, malformed or newly introduced? If so, the control flow is doing useful work.

Explore the free Crabrix lessons →