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
OptionandResult. - When to chain with
and_thenand when to transform withmap. - How to use
transposeandflattenon nested types. - How to use
inspectandinspect_errfor zero-cost debugging. - The
?operator and theFromResidualtrait that it uses. - How to select between
OptionandResult, 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
| Scenario | Use |
|---|---|
| A value may be present or absent, and the absence needs no explanation | Option<T> |
| An operation may fail, and the failure has a reason that the caller might use | Result<T, E> |
Map lookup, Iterator::find, optional struct field | Option<T> |
| I/O, parsing, network calls, validation | Result<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
| Method | On Option | On Result |
|---|---|---|
map(f) | Some(x) → Some(f(x)) | Ok(x) → Ok(f(x)) |
map_err(f) | N/A | Err(e) → Err(f(e)) |
and_then(f) | flatMap over Option | flatMap over Result |
or_else(f) | alternative Option | recovery from Err |
filter(p) | Some(x) if p(x), else None | N/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 unchanged | side effect on Ok, value unchanged |
inspect_err(f) | N/A | side effect on Err, value unchanged |
zip(other) | (Some(a), Some(b)) → Some((a,b)) | N/A |
ok() | N/A | Result<T,E> → Option<T> |
ok_or(e) | Option<T> → Result<T,E> | N/A (bool::ok_or is 1.98: true → Ok(())) |
Code Examples
| File | Description |
|---|---|
02_01_option_combinators.rs | map, and_then, or_else, filter, unwrap_or*, map_or, zip |
02_02_result_combinators.rs | map, map_err, and_then, or_else, ok, err, map_or, unwrap_or* |
02_03_transpose_flatten_inspect.rs | transpose, 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.rs | How to select between Option and Result, and the conversions ok_or, ok_or_else, ok |
02_18_bool_ok_or.rs | bool::ok_or / ok_or_else (1.98): predicates as Result<(), E> |