Skip to content
beginner Phase 5 · Enums, Option, Result, and Pattern Matching

Option and the End of Null

Represent possibly-absent values with Some and None and unwrap them safely.

45m
0 problems
Topic Progress 0%

Absence Belongs in the Type: What Replaced Null

Tony Hoare called the null reference his "billion-dollar mistake": a value that looks like a valid reference but points at nothing, turning every dereference into a question the compiler never asks. Rust's answer is to refuse the type entirely. There is no null keyword, no Nil, no way to make an &str that means "nothing". What you get instead is an enum, defined in the standard library as:

enum Option<T> {
    Some(T),
    None,
}

That is the whole mechanism. A function that might not produce a value returns Option<T>, and its signature now says so — fn find_user(id: u64) -> Option<User> cannot quietly return a fake User or a dangling pointer, because there is no such thing to return. The compiler forces every caller to consider both shapes, which moves the null check from "did you remember?" to "the type made you".

The type also documents where absence can occur. Reading a key from a cache, parsing a value, searching a slice, and splitting a string are all operations with a legitimate "not found" outcome, and they all return Option:

let users = vec![(1, "ada"), (2, "grace")];
let found = users.iter().find(|(id, _)| *id == 2).map(|(_, name)| *name);
assert_eq!(found, Some("grace"));

let missing = users.iter().find(|(id, _)| *id == 9).map(|(_, name)| *name);
assert_eq!(missing, None);

Two details that surprise newcomers. First, Option<T> is just an enum, so it takes the same tools you already know: match, if let, matches!, derives, and methods — there is no special syntax to learn for the type itself. Second, Some and None are in the prelude, so you write them unqualified; mentally expand None to Option::<T>::None and the constructor story from Enumerations with Data covers this type completely.

As for cost: Option<&T> and Option<Box<T>> are the same size as the pointer, because the null bit pattern doubles as the discriminant. Absence is encoded in a value you were already carrying, and the panic-on-dereference bug class it replaces never reaches your process at all. That combination — impossible states unrepresentable, zero space overhead — is why Option is not a compromise in idiomatic Rust but the default way to spell "maybe".

Option also participates fully in the trait ecosystem, which quietly shapes the code you write. It implements PartialEq, so tests compare options directly: assert_eq!(cache.get("theme"), Some("dark")). It implements Ord, with None sorting before every Some, so vec![None, Some(2), None, Some(1)].sort() yields None, None, Some(1), Some(2) — useful for "missing last" orderings. And it plugs into iterator chains, where its real power shows:

let ids: Vec<u64> = lines
    .iter()
    .filter_map(|l| l.parse::<u64>().ok())   // drops the Nones, keeps the Ok values
    .collect();

filter_map is where Option stops being defensive and becomes productive: "parse everything, keep what parses" is one combinators-long instead of a loop with a conditional push. The standard library leans on this relentlessly — Vec::pop returns Option<T> rather than a sentinel, HashMap::get returns Option<&V> rather than a default-constructed value, and Iterator::max returns Option<Item> because an empty iterator has no maximum. Each of those signatures is a small piece of documentation about when absence is legitimate, and reading APIs this way — "what does the return type say about the empty case?" — is the habit this topic is meant to install.

Getting the Value Out: Four Ways, Four Costs

An Option<T> is a package you must open, and the standard library gives you four families of opener, each with a different contract.

match — total and explicit. The compiler checks that you handled both arms, and the arms can do real work:

match config.get("timeout") {
    Some(raw) => raw.parse().unwrap_or(30),
    None => 30,
}

if let / let-else — the one-shape case. Use it when only one arm matters and the other is genuinely "nothing to do". The let-else form keeps the happy path flat:

let Ok(id) = query_param("id").and_then(|s| s.parse::<u64>()) else {
    return Response::bad_request("missing id");
};

Defaults — unwrap_or and friends. These collapse the None arm into a value. unwrap_or(default) computes its argument eagerly — fine for constants, wasteful when the default is expensive — which is what unwrap_or_else(|| expensive()) and unwrap_or_default() exist for:

let timeout = env_timeout.unwrap_or(30);            // eager, fine for a constant
let label = cache_name.unwrap_or_else(|| build_label());  // lazy: only runs on None
let count = optional_count.unwrap_or_default();     // Default::default() → 0

Panics — unwrap and expect. Both consume the Option and panic on None; expect takes a message that appears in the panic output:

let raw = headers.get("content-length").expect("guaranteed by middleware");
// thread 'main' panicked at src/main.rs:12:45:
// guaranteed by middleware
// note: run with `RUST_BACKTRACE=1` ...

expect is strictly better than unwrap in production code because the message records why you believed None impossible — turning a bare "called Option::unwrap() on a None value" into documentation of an invariant. The honest policy: panicking is acceptable when None would be a bug — a static you initialized yourself, an index you just validated, test code, and throwaway scripts. Every other None is a normal outcome that deserves a match, a default, or a propagated error. If your team wants the policy enforced rather than discussed, Clippy's unwrap_used lint flags every occurrence in library code.

Two conversions round out the extraction kit, both about reshaping the package instead of opening it. cloned() (or copied() for Copy types) lifts a borrowed option into an owned one — the fix for the extremely common Option<&T> that must become Option<T>:

let first: Option<&String> = users.first();
let owned: Option<String> = first.cloned();      // no unwrap in sight
let num: Option<u8> = Option::from(&7u8).copied();

And transpose() flips the two enum layers when Option and Result meet, which happens every time you parse an optional field:

let raw: Option<&str> = json.get("retries");
let parsed: Option<Result<u32, _>> = raw.map(|s| s.parse());
let parsed: Result<Option<u32>, _> = parsed.transpose();  // handle the error once, keep the optionality

Without transpose you would juggle Option<Result<..>> by hand; with it, the error path flows through normal ? handling while "field was absent" stays a legitimate value. A compact decision guide for the whole topic:

Situation Tool
absence is a normal outcome with a sensible fallback unwrap_or / unwrap_or_else
absence is an error the caller must see ok_or(err) then ?
None would mean a bug in your own invariant expect("reason")
only the present case needs work if let / let-else
both cases produce a value match

Combinators: `map`, `and_then`, `filter`, `ok_or`

Once you have an Option, the reflex should be to transform it while it stays wrapped, rather than unwrapping, operating, and re-wrapping. The combinators are ordinary enum methods, and four of them cover daily work.

map applies a function to the inner value, leaving None untouched:

let name: Option<String> = Some("ada");
let upper = name.map(|s| s.to_uppercase());   // Some("ADA")
let absent: Option<String> = None;
assert_eq!(absent.map(|s| s.to_uppercase()), None);

and_then — sometimes called flat_map — is for functions that already return an Option, so you do not end up with nested Some(Some(..)):

fn parse_port(s: &str) -> Option<u16> { s.parse().ok() }

let candidate: Option<&str> = line.get(5..);   // the part after "port:"
let port = candidate.and_then(parse_port);     // Option<u16>, no Option<Option<u16>> anywhere

The rule is simple: map for T -> U, and_then for T -> Option<U>. Using map with an Option-returning function is the classic beginner bug — it type-checks into Option<Option<U>> and then usually fails one line later, which is the compiler catching you before production does.

filter keeps the value only when a predicate agrees:

let even = Some(4).filter(|n| n % 2 == 0);   // Some(4)
let odd = Some(3).filter(|n| n % 2 == 0);    // None

And ok_or bridges to error handling by turning absence into an error — the doorway to the next topic:

let id: Option<u64> = params.get("id").and_then(|s| s.parse().ok());
let id: Result<u64, AppError> = id.ok_or(AppError::MissingParam("id"));

Chains read left to right and stay one expression, which is why combinators dominate idiomatic code: user.and_then(|u| u.settings).map(|s| s.theme).unwrap_or(Theme::Dark) is a single line covering lookup, navigation, and default. The escape hatch remains legitimate — when a chain spans three methods and a custom condition per step, a plain match with named intermediate values is easier to read and debug, and the compiler will not judge you. Combinators are a tool for shaping expressions, not a purity test.

The full daily-driver set, with signatures worth memorizing:

Method Signature shape Result
map Option<T> + T -> U Option<U>
and_then Option<T> + T -> Option<U> Option<U> (flattened)
filter Option<T> + T -> bool Option<T> (dropped if false)
or_else Option<T> + () -> Option<T> alternative only on None
zip two Option<T>s Option<(T, U)>, None if either is None
ok_or Option<T> + error Result<T, E>
get_or_insert_with &mut Option<T> + factory fills None in place, returns the value

or_else is the lazy sibling of or: primary.or_else(query_secondary) consults the second source only when the first came up empty, mirroring the unwrap_or_else discipline. get_or_insert_with deserves special mention for caches — it fills an empty slot in place without a double lookup, unlike the if x.is_none() { x = Some(build()) } pattern that rebuilds on every access.

Combinators compose with iterators to the point where much of a real service is one chain:

let themes: Vec<String> = rows
    .iter()
    .filter_map(|row| row.get("theme"))     // Option per row; drop the missing ones
    .cloned()                                 // Option<&String> -> Option<String>
    .collect();

Each step keeps a single Option flowing through, and filter_map performs the "maybe present, keep if so" dance in one place instead of scattering conditionals. The tests practically write themselves at this level — assert_eq!(normalize_title(" x ").as_deref(), Some("x")) pins the behavior of a whole chain in one line — and that ease of testing is the practical reward for keeping transformations total: no unwrap means no panic path means a test can probe any input without special protection.

Quiz

1. How does Rust express "a value that might not exist," and why can't a function return a fake `User` in its place?

Question 1 options

2. `config.get("timeout")` returns `Option<String>`. What does `config.get("timeout").unwrap_or_else(|| build_default())` do?

Question 2 options

3. What is the difference between `expect("msg")` and `unwrap()` on a `None`?

Question 3 options

4. `value.map(parse_port)` produces `Option<Option<u16>>`. What went wrong, and what is the fix?

Question 4 options

Flashcards

Question

What is `Option<T>` in the standard library, and how does it eliminate null?

Answer

`enum Option<T> { Some(T), None }` — an ordinary enum. Rust has no null value, so "maybe a T" is expressed as this type, making absence visible in every signature and forcing callers to handle both variants. `Some`/`None` are in the prelude.

Question

Compare the safe extraction tools: `match`, `if let`, `let-else`, `unwrap_or*`.

Answer

`match`: total, compiler-checked, both arms can do work. `if let`: one shape handled, no check on the rest. `let-else`: flat happy path, diverging else branch (`return`/`panic!`). `unwrap_or(x)`/`unwrap_or_else(f)`/`unwrap_or_default()`: collapse `None` to a value, eager / lazy / `Default` respectively.

Question

When is `unwrap()` or `expect()` acceptable in real code?

Answer

Only when `None` would be a genuine bug: values you just constructed or statically initialized, test code, examples, and scripts. `expect("reason")` is preferred in those spots because its message documents the invariant. For normal absence — missing keys, not-found searches — use `match`, a default, or an error.

Question

What is the rule distinguishing `map` from `and_then` on `Option`?

Answer

`map` is for `T -> U` (result gets wrapped: `Some(U)`), `and_then` is for `T -> Option<U>` (already-wrapped result, flattened). Passing an Option-returning function to `map` yields the classic `Option<Option<U>>` nesting.

Question

How do you turn an `Option` into a `Result`, and why?

Answer

`ok_or(error)` (or `ok_or_else(|| error)`) converts `Option<T>` into `Result<T, E>`, turning "nothing found" into a describable failure. Use it when absence at this point is actually an error — e.g. a required query parameter — so the failure carries a message instead of being silently default.

Revision Notes

Key Takeaways

  • 1. Rust has no null; `Option<T>` puts absence in the type so every caller must handle it — the compiler does the remembering
  • 2. `match` and `let-else` are the total, checked extractors; `if let` handles one shape and drops the check for the rest
  • 3. `unwrap_or` is eager, `unwrap_or_else` is lazy, `unwrap_or_default` uses `Default` — pick by cost of the fallback
  • 4. `expect` outclasses `unwrap` by documenting the invariant in the panic message; both are for bugs-only situations
  • 5. `map` wraps (`T -> U`), `and_then` flattens (`T -> Option<U>`), and `ok_or` converts absence into a descriptive error

Interview Tips

  • • "Why doesn't Rust have null?" — the billion-dollar mistake; absence is a variant of `Option<T>`, so it appears in signatures and exhaustiveness checking instead of hiding behind a valid-looking reference
  • • "`unwrap_or` vs `unwrap_or_else`?" — eager vs lazy default; show you think about paying for expensive defaults only on the `None` path
  • • "Is `unwrap` ever fine?" — yes, when `None` is a bug (tests, invariants you just established); state the policy, mention `expect`'s message and Clippy's `unwrap_used` lint for libraries
  • • "`map` or `and_then`?" — give the `T -> U` versus `T -> Option<U>` rule and the `Option<Option<T>>` failure mode that mixing them up produces

Cheat Sheet

Option

Definition: enum Option<T> { Some(T), None } — no null in the language; absence is a variant.

Extraction toolkit:

Tool Use when Checks both cases?
match opt { Some(v) => .., None => .. } both arms do real work yes (exhaustive)
if let Some(v) = opt { .. } only the present case matters no
let Some(v) = opt else { return .. }; keep the happy path flat else must diverge
opt.unwrap_or(default) a constant default is fine collapses to value
opt.unwrap_or_else(f) the default is expensive lazy
opt.expect("why None is a bug") None would be a programming error panics on None

Combinator chain (left to right):

user.and_then(|u| u.settings)   // T -> Option<U>: flatten
     .map(|s| s.theme)          // T -> U: wrap
     .filter(|t| t.is_active()) // keep only if predicate holds
     .unwrap_or(Theme::Dark);   // default at the end

Bridge to errors: opt.ok_or(AppError::NotFound) — Option for normal absence, Result when absence must carry a message.

Policy: panic (unwrap/expect) only for impossible-None invariants; everything else gets a match, a default, or a propagated error.

Continue Learning

Before You Start

Related Reading