Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

2.1 · Result and Option: Beyond the Basics

Domain 2 — Error Handling Ecosystem Duration: ~15 minutes Library components: std::option::Option, std::result::Result, all their methods

Introduction

Option<T> and Result<T, E> are the two primary types of Rust error handling. Most introductions stop after unwrap and a basic match. With the full combinator API, you can write clear pipelines without nested if let blocks or many early returns.

This tutorial shows:

  • The full set of transformation methods on Option and Result.
  • When to chain with and_then and when to transform with map.
  • How to use transpose and flatten on nested types.
  • How to use inspect and inspect_err for zero-cost debugging.
  • The ? operator and the FromResidual trait that it uses.
  • How to select between Option and Result, and how to convert between them.

Option Combinators

Option<T> represents a value that is present (Some(T)) or absent (None). Its API has many methods that operate on the inner value while the value stays in the Option.

Figure: Option Combinator Pipeline

map: Transform the inner value

map applies a function to the value in Some(T) and returns Some(U). On None, it returns None and does not call the function.

// A lookup that can fail: id 1 is "alice", id 2 is "bob", each other id gives None.
fn find_user(id: u32) -> Option<String> { /* ... */ }

let length: Option<usize> = find_user(1).map(|u| u.len());
// Some("alice") → Some(5)

let missing: Option<usize> = find_user(99).map(|u| u.len());
// None → None (the closure does not run)

and_then: Chain operations that may themselves fail

Use and_then when the transformation function itself returns an Option. and_then is the flatMap operation of Option: it prevents a nested Option<Option<T>>.

// find_display_name(&str) -> Option<String>: only "alice" has a display name.

// find_user(1) → Some("alice") → find_display_name → Some("Alice Wonderland")
let display = find_user(1).and_then(|u| find_display_name(&u));

// find_user(2) → Some("bob") → find_display_name → None (no display name for bob)
let no_display = find_user(2).and_then(|u| find_display_name(&u));

// find_user(99) → None (and_then short-circuits and does not call the closure)
let no_user = find_user(99).and_then(|u| find_display_name(&u));

or_else: Provide a fallback on None

or_else supplies an alternative Option when the value is absent. The closure is lazy: it runs only when the value is None.

// find_user(99) is None, so the closure runs.
let fallback = find_user(99).or_else(|| Some(String::from("anonymous")));
// None → Some("anonymous")

filter, unwrap_or, unwrap_or_default, map_or, zip

// filter: keep Some only if the predicate returns true
let long = find_user(1).filter(|u| u.len() > 4); // Some("alice"): the length is 5
let short = find_user(1).filter(|u| u.len() > 10); // None

// unwrap_or: default value on None (eager: the argument is always evaluated)
let name = find_user(99).unwrap_or(String::from("guest")); // "guest"

// unwrap_or_default: use Default::default() on None
let empty: String = find_user(99).unwrap_or_default(); // ""

// unwrap_or_else: compute the default lazily (no allocation when the value is Some)
let lazy = find_user(99).unwrap_or_else(|| format!("user-{}", 99)); // "user-99"

// map_or: a default value for None and a map for Some, in one call
let len = find_user(1).map_or(0, |u| u.len()); // 5
let zero = find_user(99).map_or(0, |u| u.len()); // 0

// zip: combine two Options into Option<(A, B)>. One None gives None.
let pair = find_user(1).zip(Some(42_u32)); // Some(("alice", 42))

02_01_option_combinators.rs prints:

map: username length = Some(5)
map on None: None

and_then (found): Some("Alice Wonderland")
and_then (inner None): None
and_then (outer None): None

or_else: Some("anonymous")
or_else (Some already): Some("alice")

filter (passes): Some("alice")
filter (fails): None

unwrap_or: "guest"
unwrap_or_default: ""
unwrap_or_else: "user-99"

map_or (Some): 5
map_or (None): 0

zip (both Some): Some(("alice", 42))
zip (one None): None

All assertions passed.

Result Combinators

Result<T, E> represents success (Ok(T)) or failure (Err(E)). Its combinator API has the same structure as the Option API, with more methods that operate on the error.

map and map_err

// lookup(key: &str) -> Result<String, ConfigError>
// "port" gives Ok("8080") and "host" gives Ok("localhost").
// Each other key gives Err(ConfigError::Missing(key)).

// map: transform the Ok value
let upper = lookup("host").map(|s| s.to_uppercase());
// Ok("localhost") → Ok("LOCALHOST")

// map_err: transform the Err value (for example, to convert the error type)
// {e} uses the Display impl of ConfigError, which prints "missing key: missing".
let stringified = lookup("missing").map_err(|e| format!("config error: {e}"));
// Err(Missing("missing")) → Err("config error: missing key: missing")

and_then: Chain fallible operations

// parse_port(&str) -> Result<u16, ConfigError>
// It gives Err(ConfigError::Invalid(..)) if the text is not a u16.

// Look up the "port" string, then parse it as u16.
let port: Result<u16, ConfigError> = lookup("port").and_then(|s| parse_port(&s));
// Ok("8080") → Ok(8080)

let no_key = lookup("missing").and_then(|s| parse_port(&s));
// Err(Missing(...)) → and_then short-circuits and does not call parse_port

let bad_port: Result<u16, ConfigError> = Ok(String::from("not-a-number"))
    .and_then(|s| parse_port(&s));
// Ok("not-a-number") → Err(Invalid(...)): the second step fails

or_else, ok, err, map_or

// or_else: recover from an error
// The turbofish gives the error type of the new Result, which the compiler cannot infer.
let recovered =
    lookup("missing").or_else(|_| Ok::<String, ConfigError>(String::from("default")));
// Err(Missing(...)) → Ok("default")

// ok: Result<T, E> → Option<T>, discards the error
let opt: Option<String> = lookup("host").ok();     // Some("localhost")
let none: Option<String> = lookup("missing").ok(); // None

// err: Result<T, E> → Option<E>, discards the value
let err_opt: Option<ConfigError> = lookup("missing").err(); // Some(Missing(...))

// map_or: a default value for Err and a map for Ok, in one call
let len = lookup("host").map_or(0, |s| s.len()); // 9, the length of "localhost"

02_02_result_combinators.rs prints:

map Ok: Ok("LOCALHOST")
map Err (passthrough): Err(Missing("missing"))

map_err: Err("config error: missing key: missing")

and_then (success chain): Ok(8080)
and_then (first fails): Err(Missing("missing"))
and_then (second fails): Err(Invalid("\"not-a-number\" is not a valid port"))

or_else (recover): Ok("default")
or_else (Ok passthrough): Ok("localhost")

ok() on Ok: Some("localhost")
ok() on Err: None

err() on Err: Some(Missing("missing"))
err() on Ok: None

map_or (Ok): 9
map_or (Err): 0

unwrap_or: "127.0.0.1"
unwrap_or_else: "fallback (missing key: missing)"
unwrap_or_default: ""

All assertions passed.

transpose, flatten, and inspect

transpose: bridge Option and Result

transpose is a very useful conversion in the standard library that few programmers know. It exchanges the order of the two nested types:

  • Option<Result<T, E>> ↔ Result<Option<T>, E>

You need this conversion very frequently for optional configuration values. When such a value is present, it must also parse correctly:

// parse_timeout(&str) -> Result<u64, String>: parses a timeout in milliseconds.
// In each case, `map` gives an Option<Result<u64, String>>,
// and `transpose` changes it into a Result<Option<u64>, String>.

// Present and valid
let raw: Option<&str> = Some("500");
let parsed = raw.map(parse_timeout).transpose();
// Some(Ok(500)) → Ok(Some(500))

// Present but invalid
let bad: Option<&str> = Some("not-a-number");
let parsed_bad = bad.map(parse_timeout).transpose();
// Some(Err(..)) → Err("\"not-a-number\" is not a valid timeout in ms")

// Absent
let absent: Option<&str> = None;
let parsed_absent = absent.map(parse_timeout).transpose();
// None → Ok(None)

The pattern optional_value.map(parse).transpose()? is frequent in code that loads a configuration. It returns early on a parse error. An absent key is not an error: the result is Ok(None).

flatten: unwrap one level of nesting

// Option<Option<T>> → Option<T>
let nested: Option<Option<u32>> = Some(Some(42));
assert_eq!(nested.flatten(), Some(42));
assert_eq!(Some(None::<u32>).flatten(), None); // an inner None gives None

// Result<Result<T, E>, E> → Result<T, E> (the two error types are the same type E)
let nested_ok: Result<Result<u32, &str>, &str> = Ok(Ok(7));
assert_eq!(nested_ok.flatten(), Ok(7));
// Ok(Err("inner")) gives Err("inner"), and Err("outer") gives Err("outer")

inspect: peek without consuming

inspect calls a closure that has a side effect (for example, logging) on a reference to the inner value. The chain continues with the same value:

// `log` and `err_log` are empty Vec<String> values, declared with `let mut`.
let result = parse_timeout("250")
    .inspect(|ms| log.push(format!("parsed timeout: {ms}ms"))); // ms: &u64
// result is still Ok(250), and log has one new entry

let failed = parse_timeout("bad")
    .inspect_err(|e| err_log.push(format!("parse failed: {e}"))); // e: &String
// failed is still Err(...), and err_log has one new entry

inspect and inspect_err are especially useful when you debug a combinator chain. You can insert them at any position, and the structure of the code stays the same.

02_03_transpose_flatten_inspect.rs prints:

transpose Some(Ok):  Ok(Some(500))
transpose Some(Err): Err("\"not-a-number\" is not a valid timeout in ms")
transpose None:      Ok(None)

Option::flatten Some(Some): Some(42)
Option::flatten None:       None
Option::flatten Some(None): None

Result::flatten Ok(Ok):  Ok(7)
Result::flatten Ok(Err): Err("inner")
Result::flatten Err:     Err("outer")

inspect Ok: Ok(250)
inspect_err: Err("\"bad\" is not a valid timeout in ms")

Option inspect: Some(99)

All assertions passed.

The ? Operator and FromResidual

The ? operator desugars approximately to a match expression with an early return. The figure shows the control flow, and the snippet after the figure shows the match.

Figure: How the ? Operator Works

// `expr?` on a Result is approximately this expression:
match expr {
    Ok(v)  => v,                         // success: `v` is the value of `expr?`
    Err(e) => return Err(From::from(e)), // failure: convert the error and return early
}

The From::from(e) call lets ? convert between error types automatically. The only condition is a From impl that connects the two error types:

// AppError is an enum with three variants: Parse(ParseIntError), NotFound(u32), Forbidden.
impl From<ParseIntError> for AppError {
    fn from(e: ParseIntError) -> Self { Self::Parse(e) }
}

// lookup_record(u32) -> Result<String, AppError>
// check_access(&str) -> Result<(), AppError>
fn handle_request(raw_id: &str) -> Result<String, AppError> {
    let id = raw_id.trim().parse::<u32>()?; // ParseIntError → AppError::Parse through From
    let record = lookup_record(id)?;         // Err(AppError::NotFound(..)) returns early
    check_access(&record)?;                  // Err(AppError::Forbidden) returns early
    Ok(format!("approved: {record}"))
}

? also operates on Option in a function that returns Option:

fn find_first_even(nums: &[i32]) -> Option<i32> {
    // `find` returns Option<i32>. On None, `?` returns None from the function.
    let even = nums.iter().copied().find(|n| n % 2 == 0)?;
    Some(even * 10)
}

The FromResidual trait is the mechanism that ? uses. Result<T, E> implements FromResidual<Result<Infallible, F>> for each F where E: From<F>. The trait is unstable (feature try_trait_v2), so an implementation for a custom type requires nightly as of Rust 1.99.

main() can return Result<(), E>, which lets you use ? at the top level:

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // On Err, `?` converts the ParseIntError into a Box<dyn Error> and returns from main.
    // The process then prints the Debug form of the error and exits with code 1.
    let _n: u32 = "42".parse()?;
    Ok(())
}

02_04_question_mark.rs prints:

handle_request("2"): Ok("approved: order:keyboard")
handle_request("abc"): Err(Parse(ParseIntError { kind: InvalidDigit }))
handle_request("99"): Err(NotFound(99))
handle_request("1"): Err(Forbidden)

find_first_even([1,3,4,6]): Some(40)
find_first_even([1,3,5]):   None

All assertions passed.

Option vs Result: Choosing the Right Type

ScenarioUse
A value may be present or absent, and the absence needs no explanationOption<T>
An operation may fail, and the failure has a reason that the caller might useResult<T, E>
Map lookup, Iterator::find, optional struct fieldOption<T>
I/O, parsing, network calls, validationResult<T, E>

Convert between the two types at API boundaries:

// find_capital(&str) -> Option<&str>: a lookup in a map of capitals ("Germany" → "Berlin").
// parse_percentage(&str) -> Result<u8, ParseError>: accepts the integers 0 to 100.

// Option → Result: add an error value
let r: Result<&str, &str> = find_capital("Germany").ok_or("country not found");
// Some("Berlin") → Ok("Berlin")
let r2: Result<&str, String> = find_capital("Atlantis")
    .ok_or_else(|| "Atlantis is not in the database".to_owned());
// None → Err(..): ok_or_else makes the String only on None

// Result → Option: drop the error
let opt: Option<u8> = parse_percentage("50").ok();   // Some(50)
let none: Option<u8> = parse_percentage("abc").ok(); // None

// bool → Result<(), E> (1.98): a check, not a value
// `ready` is a bool and `name` is a &str. On Err, `?` returns early from the function.
ready.ok_or("not ready")?;               // true → Ok(()), false → Err
ready.ok_or_else(|| format!("{name} is not ready"))?;  // lazy error

General rule: Use Option if the only meaningful information at the call site is success (with the value) or no success. Use Result if the failure reason changes the response of the caller (retry, report, a different log entry).

Summary

MethodOn OptionOn Result
map(f)Some(x) → Some(f(x))Ok(x) → Ok(f(x))
map_err(f)N/AErr(e) → Err(f(e))
and_then(f)flatMap over OptionflatMap over Result
or_else(f)alternative Optionrecovery from Err
filter(p)Some(x) if p(x), else NoneN/A
flatten()Option<Option<T>> → Option<T>Result<Result<T,E>,E> → Result<T,E>
transpose()Option<Result<T,E>> → Result<Option<T>,E>Result<Option<T>,E> → Option<Result<T,E>>
inspect(f)side effect on Some, value unchangedside effect on Ok, value unchanged
inspect_err(f)N/Aside effect on Err, value unchanged
zip(other)(Some(a), Some(b)) → Some((a,b))N/A
ok()N/AResult<T,E> → Option<T>
ok_or(e)Option<T> → Result<T,E>N/A (bool::ok_or is 1.98: true → Ok(()))

Code Examples

FileDescription
02_01_option_combinators.rsmap, and_then, or_else, filter, unwrap_or*, map_or, zip
02_02_result_combinators.rsmap, map_err, and_then, or_else, ok, err, map_or, unwrap_or*
02_03_transpose_flatten_inspect.rstranspose, flatten on Option and Result, inspect, inspect_err
02_04_question_mark.rs? operator, From conversions, ? on Option, main() -> Result
02_05_option_vs_result.rsHow to select between Option and Result, and the conversions ok_or, ok_or_else, ok
02_18_bool_ok_or.rsbool::ok_or / ok_or_else (1.98): predicates as Result<(), E>