The Option Type
No Null in Rust
Section titled “No Null in Rust”Many languages allow any variable to hold a “null” or “nil” value, leading to null-pointer exceptions at runtime. Rust takes a different approach: there is no null. When a value might be absent, you use Option<T> from the standard library.
Option<T> is an enum defined as:
enum Option<T> { Some(T), None,}Some(value) wraps a present value of type T. None represents the absence of a value. The compiler forces you to handle both cases before you can use the inner value — null-pointer bugs become compile-time errors.
Because Option<T> is so common, Some and None are in scope everywhere without needing Option:: prefix.
Handling Option<T> with match
Section titled “Handling Option<T> with match”The most explicit way to handle an Option<T> is a match expression:
fn divide(a: f64, b: f64) -> Option<f64> { if b == 0.0 { None } else { Some(a / b) }}
match divide(10.0, 2.0) { Some(result) => println!("Result: {}", result), None => println!("Cannot divide by zero"),}The compiler rejects code that only handles Some without None — the match must be exhaustive.
if let for the Happy Path
Section titled “if let for the Happy Path”When you only care about the Some case, if let is cleaner:
fn divide(a: f64, b: f64) -> Option<f64> { if b == 0.0 { None } else { Some(a / b) }}
if let Some(value) = divide(10.0, 2.0) { println!("Got: {}", value);}The body executes only when the value is Some. Add else to handle None if needed.
Let Chains — if let Plus Conditions (Rust 2024)
Section titled “Let Chains — if let Plus Conditions (Rust 2024)”Reaching into a nested Option used to mean nesting if let blocks, because if let could not be combined with &&. The Rust 2024 edition allows let chains: let bindings and boolean tests joined with && inside one condition.
#[derive(Debug)]struct User { name: String, age: Option<u32>,}
fn find_user(id: u32) -> Option<User> { if id == 1 { Some(User { name: String::from("Ada"), age: Some(36) }) } else { None }}
fn main() { // One flat condition: find the user, unwrap the age, compare it. if let Some(user) = find_user(1) && let Some(age) = user.age && age >= 18 { println!("{} is an adult ({age})", user.name); }
if let Some(user) = find_user(2) && let Some(age) = user.age { println!("{} is {age}", user.name); } else { println!("no user 2"); }}Compiling…
Every let in the chain binds for the rest of the chain and for the body, so user is in scope for the second let and age is in scope for the comparison. An else branch still works — it runs when any link in the chain fails.
This one is edition-gated. The same code on edition = "2021" is rejected with error: let chains are only allowed in Rust 2024 or later.
let else — Bind or Bail, in One Line
Section titled “let else — Bind or Bail, in One Line”let else binds the Some case directly and requires its else branch to diverge (return, break, continue, or panic!), so everything after the let can treat the binding as unconditional — no nested if let block, no separate else scope to manage:
fn first_word(s: &str) -> Option<&str> { s.split_whitespace().next()}
fn shout_first_word(s: &str) { let Some(word) = first_word(s) else { println!("nothing to shout"); return; }; println!("{}!", word.to_uppercase());}
fn main() { shout_first_word("hello world"); shout_first_word(" ");}HELLO!nothing to shoutmatches! — a Boolean Test Without Destructuring
Section titled “matches! — a Boolean Test Without Destructuring”matches!(value, pattern) checks whether a value matches a pattern and returns true/false, without binding anything. It is the right tool when you only need a yes/no answer to “is this Some? is this in range?”:
fn main() { let maybe: Option<i32> = Some(7); println!("is some: {}", matches!(maybe, Some(_))); println!("is some and positive: {}", matches!(maybe, Some(n) if n > 0));
let none: Option<i32> = None; println!("none is some: {}", matches!(none, Some(_)));}is some: trueis some and positive: truenone is some: falsematches! desugars to exactly the match value { pattern => true, _ => false } you would otherwise write by hand — it exists purely to remove that boilerplate for the common “just tell me yes or no” case.
unwrap_or — Providing a Default
Section titled “unwrap_or — Providing a Default”unwrap_or(default) returns the inner value if Some, or the given default if None:
let maybe: Option<i32> = None;let value = maybe.unwrap_or(0); // 0This is the safest form of “just give me a value or a fallback.” Avoid plain .unwrap() in production code — it panics on None.
map — Transforming the Inner Value
Section titled “map — Transforming the Inner Value”map applies a closure to the Some value and returns a new Option<T>. If the original is None, it passes None through unchanged:
let doubled = Some(5).map(|x| x * 2); // Some(10)let nothing: Option<i32> = None;let still_none = nothing.map(|x| x * 2); // Nonemap is essential for chaining transformations without unwrapping intermediate Option<T> values.
The Full Combinator Tour
Section titled “The Full Combinator Tour”Beyond map and unwrap_or, Option<T> has a family of combinators for chaining fallible logic without ever calling .unwrap():
fn first_word(s: &str) -> Option<&str> { s.split_whitespace().next()}
fn parse_first_word_len(s: &str) -> Option<usize> { let word = first_word(s)?; // ? on Option: return None early Some(word.len())}
fn main() { // and_then: chain another Option-returning step (like flatMap) let nested: Option<i32> = Some(4).and_then(|n| if n > 0 { Some(n * n) } else { None }); println!("and_then: {:?}", nested);
// ok_or: Option -> Result, supplying an error for None let maybe_id: Option<u32> = None; let result: Result<u32, &str> = maybe_id.ok_or("missing id"); println!("ok_or: {:?}", result);
// ? on Option: propagate None early, same shape as ? on Result println!("? on Option: {:?}", parse_first_word_len("hello world")); println!("? on Option: {:?}", parse_first_word_len(""));
// as_ref: Option<T> -> Option<&T> without consuming the original let owned: Option<String> = Some(String::from("hi")); let len = owned.as_ref().map(|s| s.len()); println!("as_ref: {:?}, owned still usable: {:?}", len, owned);
// as_deref: Option<String> -> Option<&str> let maybe_string: Option<String> = Some(String::from("deref me")); let as_str: Option<&str> = maybe_string.as_deref(); println!("as_deref: {:?}", as_str);
// take: move the value out of the Option, leaving None behind let mut slot: Option<i32> = Some(10); let taken = slot.take(); println!("take: taken={:?} slot={:?}", taken, slot);
// zip: combine two Options into Option<(A, B)>, None if either is None let a: Option<i32> = Some(1); let b: Option<&str> = Some("one"); println!("zip: {:?}", a.zip(b)); let c: Option<i32> = None; println!("zip with None: {:?}", a.zip(c));}and_then: Some(16)ok_or: Err("missing id")? on Option: Some(5)? on Option: Noneas_ref: Some(2), owned still usable: Some("hi")as_deref: Some("deref me")take: taken=Some(10) slot=Nonezip: Some((1, "one"))zip with None: Noneand_then is to Option what flat_map is to iterators: the closure itself returns an Option, so and_then avoids the Option<Option<T>> you’d get from map with an Option-returning closure. as_ref/as_deref matter specifically because plain map on an owned Option<String> would consume it; borrowing first lets you inspect the inner value while keeping the original Option usable afterward. take() is the standard way to move a value out of a struct field that holds an Option<T> — it leaves None behind instead of requiring the whole struct to be temporarily invalid.
What the Compiler Says: ? Only Works Inside a Matching Return Type
Section titled “What the Compiler Says: ? Only Works Inside a Matching Return Type”The ? operator on Option only compiles inside a function that itself returns Option (or another type implementing the same short-circuit mechanism):
// @expect-error E0277fn first_word(s: &str) -> Option<&str> { s.split_whitespace().next()}
fn bad(s: &str) -> usize { let word = first_word(s)?; word.len()}
fn main() { println!("{}", bad("hi"));}error[E0277]: the `?` operator can only be used in a function that returns `Result` or `Option` (or another type that implements `FromResidual`) --> src/main.rs:7:29 |6 | fn bad(s: &str) -> usize { | ------------------------ this function should return `Result` or `Option` to accept `?`7 | let word = first_word(s)?; | ^ cannot use the `?` operator in a function that returns `usize`
error: aborting due to 1 previous error? is not “unwrap or return a default” — it always propagates through the same kind of wrapper the function returns. A function returning a plain usize has no None case to return into, so the compiler rejects it outright rather than guessing what you meant.
fn first_word(s: &str) -> Option<&str> { s.split_whitespace().next()}
fn parse_first_word_len(s: &str) -> Option<usize> { let word = first_word(s)?; Some(word.len())}
fn main() { let nested: Option<i32> = Some(4).and_then(|n| if n > 0 { Some(n * n) } else { None }); println!("and_then: {:?}", nested);
let maybe_id: Option<u32> = None; let result: Result<u32, &str> = maybe_id.ok_or("missing id"); println!("ok_or: {:?}", result);
println!("? on Option: {:?}", parse_first_word_len("hello world")); println!("? on Option: {:?}", parse_first_word_len(""));
let owned: Option<String> = Some(String::from("hi")); let len = owned.as_ref().map(|s| s.len()); println!("as_ref: {:?}, owned still usable: {:?}", len, owned);
let maybe_string: Option<String> = Some(String::from("deref me")); let as_str: Option<&str> = maybe_string.as_deref(); println!("as_deref: {:?}", as_str);
let mut slot: Option<i32> = Some(10); let taken = slot.take(); println!("take: taken={:?} slot={:?}", taken, slot);
let a: Option<i32> = Some(1); let b: Option<&str> = Some("one"); println!("zip: {:?}", a.zip(b));}Compiling…
flowchart TD
A[You have an Option] --> B{What do you need?}
B -->|"just yes/no, no binding"| M["matches!(opt, Some(_))"]
B -->|"a value or a cheap fallback"| U1[".unwrap_or(default)"]
B -->|"a value or an expensive/computed fallback"| U2[".unwrap_or_else(|| ...)"]
B -->|"transform the inner value, stay in Option"| MA[".map(...)"]
B -->|"chain another Option-returning step"| AT[".and_then(...)"]
B -->|"need a Result instead, with an error for None"| OK[".ok_or(err) / .ok_or_else(...)"]
B -->|"bind now or bail out of the function"| LE["let ... else { return/break/continue }"]
B -->|"inside a function that itself returns Option"| Q["? propagates None"] fn divide(a: f64, b: f64) -> Option<f64> { if b == 0.0 { None } else { Some(a / b) }}
fn find_first_even(nums: &[i32]) -> Option<i32> { nums.iter().find(|&&x| x % 2 == 0).copied()}
fn main() { // match on Option match divide(10.0, 3.0) { Some(result) => println!("10 / 3 = {:.4}", result), None => println!("Cannot divide by zero"), }
match divide(5.0, 0.0) { Some(result) => println!("result = {}", result), None => println!("Cannot divide by zero"), }
// if let let numbers = vec![1, 3, 5, 8, 9]; if let Some(even) = find_first_even(&numbers) { println!("First even: {}", even); }
// unwrap_or let missing: Option<i32> = None; println!("Default: {}", missing.unwrap_or(0));
// map let some_val: Option<i32> = Some(5); let doubled = some_val.map(|x| x * 2); println!("Doubled: {:?}", doubled);}Compiling…
Mental Model
Section titled “Mental Model”Option<T> turns “might not be here” into a value you must open before you can use — every combinator (map, and_then, unwrap_or_else, ?) is a different way of opening it, chosen by what you want to happen on None. There is no way to accidentally use the inner T without going through one of these, which is the entire mechanism that makes null-pointer-style bugs impossible: the compiler will not let you forget the None branch exists.
Trade-offs
Section titled “Trade-offs”match is the most explicit form and reads correctly to anyone regardless of how many combinator names they know, at the cost of vertical space for a long chain of fallible steps. Combinators compress that chain into one expression, but each one (and_then, ok_or_else, zip) is a piece of vocabulary the reader needs before the line makes sense. let else sits in between — as readable as match for “bind or bail,” but only usable when the failure path can genuinely diverge, which rules it out the moment you need a value on failure instead of an early return. matches! is the cheapest option when you truly only need a boolean, but the moment you also need the inner value, you’re back to if let/match.
Common Misconceptions
Section titled “Common Misconceptions”- “
is_some()followed byunwrap()cannot panic, so it’s just as safe asif let.” True that it cannot panic in that exact shape, but it checks the variant twice (once viais_some(), once insideunwrap()), and unlikeif let Some(x) = opt, it gives you no compiler-enforced place to handleNone— a reviewer has to notice the missingelsethemselves. - “
unwrap_or(x)andunwrap_or_else(|| x)always do the same thing.” They do, ifxis a plain, already-computed value. The momentxis a function call or an allocation,unwrap_orpays that cost on every invocation —SomeorNone— whileunwrap_or_else’s closure only runs onNone. - “
?on anOptionunwraps it, or panics like.unwrap()does on failure.” It never panics. OnNoneit returnsNoneimmediately from the enclosing function — which is why it only compiles inside a function whose own return type isOption(orResult, for?on aResult).
Reading AI-generated code
Section titled “Reading AI-generated code”AI assistants frequently write if x.is_some() { x.unwrap() } because it “obviously” cannot panic — but it does manually, and worse, what a single pattern match already does atomically and idiomatically, with an else branch reminding the reader that None needs handling too.
🐛 Spot the bug
fn print_balance(account: Option<i32>) { if account.is_some() { println!("balance: {}", account.unwrap()); }}Show the answer
This compiles and never panics, but it is strictly worse than the idiomatic version: there is no branch handling None at all (a caller passing None gets total silence, not even a log line), and the variant is checked twice — once via is_some(), once implicitly inside unwrap(). The fix is if let Some(balance) = account { println!("balance: {}", balance); } else { println!("balance unavailable"); } — or, better, changing the function to return the formatted String/Option<String> so the caller decides what “unavailable” should look like, instead of the message being buried inside print_balance.