Skip to content

The Option Type

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.

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.

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");
}
}

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 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 shout

matches! — 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: true
is some and positive: true
none is some: false

matches! 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(default) returns the inner value if Some, or the given default if None:

let maybe: Option<i32> = None;
let value = maybe.unwrap_or(0); // 0

This is the safest form of “just give me a value or a fallback.” Avoid plain .unwrap() in production code — it panics on None.

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); // None

map is essential for chaining transformations without unwrapping intermediate Option<T> values.

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: None
as_ref: Some(2), owned still usable: Some("hi")
as_deref: Some("deref me")
take: taken=Some(10) slot=None
zip: Some((1, "one"))
zip with None: None

and_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 E0277
fn 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));
}
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"]
Choosing an Option combinator
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);
}

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.

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.

  • “is_some() followed by unwrap() cannot panic, so it’s just as safe as if let.” True that it cannot panic in that exact shape, but it checks the variant twice (once via is_some(), once inside unwrap()), and unlike if let Some(x) = opt, it gives you no compiler-enforced place to handle None — a reviewer has to notice the missing else themselves.
  • “unwrap_or(x) and unwrap_or_else(|| x) always do the same thing.” They do, if x is a plain, already-computed value. The moment x is a function call or an allocation, unwrap_or pays that cost on every invocation — Some or None — while unwrap_or_else’s closure only runs on None.
  • “? on an Option unwraps it, or panics like .unwrap() does on failure.” It never panics. On None it returns None immediately from the enclosing function — which is why it only compiles inside a function whose own return type is Option (or Result, for ? on a Result).

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.

Which Rust edition is required for a let chain like if let Some(x) = opt && x > 2?
What does Option<T> represent in Rust?
What does unwrap_or_else(|| expensive()) do differently from unwrap_or(expensive()) when the Option is Some?
What does this print?
fn main() {
    let x: Option<i32> = Some(7);
    println!("{}", matches!(x, Some(n) if n > 10));
}
Why does this fail to compile?
fn first_word(s: &str) -> Option<&str> {
    s.split_whitespace().next()
}
fn bad(s: &str) -> usize {
    let word = first_word(s)?;
    word.len()
}
Why is if x.is_some() { x.unwrap() } considered a smell compared to if let Some(v) = x { ... }?
What does Some(1).zip(None::<&str>) evaluate to?