Option and Result Combinators

Error Handling in Practice covered ? for propagating errors and match for handling them. Between those two extremes sits a large, underused middle: the combinator methods on Option and Result that transform, chain, and unwrap them without a single match block. Reaching for map, and_then, ok_or, and friends turns nested pyramids of match into flat, readable pipelines, the same shift from imperative to declarative that Iterators Over Loops made for collections. This tutorial covers the combinators that earn their keep and the eager-vs-lazy trap hiding among them.


map and and_then: The Core Distinction

The two most important combinators look similar and are constantly confused. map transforms the inner value with a function that returns a plain value. and_then (aka flatmap) chains a function that itself returns an Option/Result, and flattens the result so you don't end up with Option<Option<T>>.

let text = "42";

// map: the closure returns a plain value → Option<i32> stays one level deep
let len: Option<usize> = Some(text).map(|s| s.len());

// and_then: the closure returns an Option → without flattening you'd get Option<Option<i32>>
let parsed: Option<i32> = Some(text).and_then(|s| s.parse().ok());

The rule: use map when your function can't fail, and_then when it returns another Option/Result. Getting this wrong is the classic Option<Option<T>> (or Result<Result<T, E>, E>) nesting bug, if a .map() gives you a doubly-wrapped type, you wanted .and_then(). This mirrors Iterator::map vs flat_map exactly, and for the same reason.


Replacing match Pyramids with Chains

The payoff is dissolving nested match. Consider looking up a user, then their config, then a field, each step fallible:

// naive: a pyramid of match, three levels deep
let timeout = match find_user(id) {
    Some(user) => match user.config() {
        Some(cfg) => match cfg.timeout {
            Some(t) => t,
            None => 30,
        },
        None => 30,
    },
    None => 30,
};

// idiomatic: a flat chain that reads top to bottom
let timeout = find_user(id)
    .and_then(|user| user.config())
    .and_then(|cfg| cfg.timeout)
    .unwrap_or(30);

Each and_then short-circuits: the moment any step yields None, the rest are skipped and the chain produces None, exactly like ? but as an expression you can keep transforming. The final unwrap_or(30) supplies the fallback. The chain is not just shorter; it removes the repeated None => 30 arms where a copy-paste mistake could hide.


Bridging Option and Result

Option and Result model different things (absence vs failure), but real code constantly converts between them, usually at the point where "missing" becomes an error worth reporting. Two methods do this:

// ok_or: Option → Result, supplying the error for the None case
let name: Result<String, MyError> = maybe_name.ok_or(MyError::MissingName);

// .ok(): Result → Option, discarding the error (when you don't care why it failed)
let parsed: Option<i32> = "42".parse::<i32>().ok();

ok_or (and its lazy sibling ok_or_else) is the idiomatic way to turn a None into a real error so it can flow through ? and your error libraries. Going the other way, .ok() drops a Result's error to get an Option, useful in an and_then chain where the reason for failure doesn't matter. map_err transforms the error type without touching the success value, the bridge that makes a foreign error fit your own enum when #[from] isn't set up.


Eager vs Lazy: The _or / _or_else Trap

Many combinators come in two forms: one taking a value (unwrap_or, ok_or, map_or) and one taking a closure (unwrap_or_else, ok_or_else, map_or_else). They produce the same result, but differ in when the fallback is computed.

// unwrap_or: the argument is evaluated ALWAYS, even when self is Some
let config = maybe_config.unwrap_or(load_default_config());  // load_default_config() ALWAYS runs

// unwrap_or_else: the closure runs ONLY when self is None
let config = maybe_config.unwrap_or_else(|| load_default_config());  // runs only if needed

Gotcha: unwrap_or(expensive()) evaluates expensive() unconditionally, before unwrap_or is even called, because arguments are evaluated eagerly in Rust. If the fallback is a function call, an allocation, or anything non-trivial, you're paying for it on every success path where it's thrown away. Use the _or_else (closure) variant for any computed fallback so the work happens only when actually needed. Reserve the plain _or form for cheap constants (unwrap_or(0), unwrap_or("")). This is the same eager-argument trap as .with_context() vs .context() in the anyhow tutorial.


Handy Specialized Combinators

A few more that replace specific match shapes:

// filter: keep Some only if a predicate holds, else None
let even = Some(4).filter(|n| n % 2 == 0);        // Some(4); Some(3) → None

// or / or_else: supply an alternative Option/Result if self is None/Err
let value = primary.or(fallback);                  // first Some wins

// transpose: swap the nesting of Option<Result<..>> ↔ Result<Option<..>>
let x: Result<Option<i32>, _> = Some("5".parse()).transpose();

// unwrap_or_default: use the type's Default when None/Err
let count: u32 = maybe_count.unwrap_or_default();  // 0

transpose is the specialist that resolves a genuinely awkward case: an Option<Result<T, E>> (e.g. "maybe I have a value, and parsing it might fail") flips into Result<Option<T>, E> so it slots into a ? chain. filter turns a value-plus-predicate into an Option without an if. These are worth recognizing rather than memorizing, when a match feels boilerplate-y, there's usually a named combinator for it.


Combinator Cheat Sheet

You wantUse
Transform the inner value (can't fail)map
Chain a step that returns Option/Resultand_then
Provide a fallback valueunwrap_or (cheap) / unwrap_or_else (computed)
Turn None into an errorok_or / ok_or_else
Turn Result into Option (drop error).ok()
Change the error typemap_err
Keep Some only if a predicate holdsfilter
Fall back to another Option/Resultor / or_else
Flip Option<Result>Result<Option>transpose
Use Default when emptyunwrap_or_default

The guiding principle: combinators are to match what iterator adapters are to for loops. A single match on an Option/Result is fine; but nested or repetitive matching is a signal to reach for a chain. And whenever a combinator has an _else twin, prefer it for any non-trivial fallback so the work stays lazy.


Key Takeaways

  • map transforms the inner value with a function returning a plain value; and_then chains a function returning another Option/Result and flattens it. A doubly-wrapped type (Option<Option<T>>) means you wanted and_then.
  • Chaining and_then short-circuits on the first None/Err, collapsing nested match pyramids into a flat pipeline that reads top-to-bottom, finished with unwrap_or/ok_or for the fallback.
  • Bridge the two types at the "absence becomes an error" boundary: ok_or/ok_or_else (OptionResult), .ok() (ResultOption), and map_err to fit a foreign error into your own enum.
  • The _or vs _or_else split is eager vs lazy: unwrap_or(expensive()) runs expensive() unconditionally. Use the closure (_or_else) form for any computed fallback; reserve _or for cheap constants.
  • Recognize the specialists, filter, or/or_else, transpose, unwrap_or_default, as replacements for specific match shapes. Combinators are to match what iterator adapters are to loops.