Skip to content
rusthacks.dev
Rust

Option in Rust and How to Stop Fearing the Null Case

Option replaces null in Rust. Learn Some and None, map, and_then, unwrap_or, the question mark operator and why unwrap on None panics.

8 min read
Summarize in AI
Flat illustration for the post “Option in Rust and How to Stop Fearing the Null Case”, composed around ownership.

Tony Hoare called the null reference his billion-dollar mistake: a value of any type might secretly be missing, and nothing in the type says so. Rust has no null. A value that may be absent has its own type, Option<T>, and the compiler makes you deal with the absent case before it lets you touch the value. This post covers how to read an Option, the methods that save you from match, and the two panics you should recognise. Everything ran on Rust 1.99, edition 2024.

The type, and the only way to look inside

src/main.rsrust
fn find_user(id: u32) -> Option<&'static str> {
    match id {
        1 => Some("ada"),
        2 => Some("grace"),
        _ => None,
    }
}

fn main() {
    for id in [1, 2, 3] {
        match find_user(id) {
            Some(name) => println!("{id}: {name}"),
            None => println!("{id}: no such user"),
        }
    }
}
Text
1: ada
2: grace
3: no such user

Option<T> is an ordinary enum, defined in the standard library as Some(T) or None, as described in Rust enums. find_user returns Some(name) when it knows the id and None when it does not, and the caller has to say what happens in each case; the match is checked for exhaustiveness like any other. Because a &'static str and an Option<&'static str> are different types, a function that returns the first can never hand you nothing. The absence is visible in the signature.

That visibility is also why you cannot treat an Option as its inner value:

src/main.rsrust
fn main() {
    let a: Option<i32> = Some(1);
    let b = a + 1;
    println!("{b:?}");
}
Text
error[E0369]: cannot add `{integer}` to `Option<i32>`
 --> src/main.rs:3:15
  |
3 |     let b = a + 1;
  |             - ^ - {integer}
  |             |
  |             Option<i32>
  |
note: `Option<i32>` does not implement `Add<{integer}>`

Option<i32> does not implement addition, because "maybe a number plus one" has no sensible answer. In another language a + 1 with a null a would crash somewhere later, or produce garbage. Here it is a build error, and the fix is to decide what absence means: a.map(|n| n + 1) keeps the absence, a.unwrap_or(0) + 1 replaces it with a default. The mismatch in the other direction is equally common:

src/main.rsrust
fn first_even(nums: &[i32]) -> Option<i32> {
    for &n in nums {
        if n % 2 == 0 {
            return n;
        }
    }
    None
}

fn main() {
    println!("{:?}", first_even(&[1, 3, 4]));
}
Text
error[E0308]: mismatched types
 --> src/main.rs:4:20
  |
1 | fn first_even(nums: &[i32]) -> Option<i32> {
  |                                ----------- expected `Option<i32>` because of return type
...
4 |             return n;
  |                    ^ expected `Option<i32>`, found `i32`
  |
  = note: expected enum `Option<i32>`
             found type `i32`
help: try wrapping the expression in `Some`
  |
4 |             return Some(n);

The function promises an Option<i32>, and return n; returns a bare i32. The compiler suggests wrapping it in Some, which is the right answer: return Some(n);. The error is one of the family in E0308, and the "try wrapping" suggestion is worth trusting.

unwrap and expect: when None is a bug

unwrap returns the inside of a Some and panics on None:

src/main.rsrust
fn find_user(id: u32) -> Option<&'static str> {
    if id == 1 { Some("ada") } else { None }
}

fn main() {
    let name = find_user(7).unwrap();
    println!("{name}");
}
Text
thread 'main' panicked at src/main.rs:6:29:
called `Option::unwrap()` on a `None` value

The message says what happened but not why it matters, and in a large program the line number is all you have. expect takes a message that says what you assumed:

src/main.rsrust
fn main() {
    let port: Option<u16> = std::env::var("APP_PORT").ok().and_then(|v| v.parse().ok());
    let port = port.expect("APP_PORT must be set to a number");
    println!("{port}");
}
Text
thread 'main' panicked at src/main.rs:3:21:
APP_PORT must be set to a number

Prefer expect("reason this cannot be None") to unwrap in any code that is not a test or a throwaway script: the message is the first thing you read at 3 a.m. Both are right when None means the program is broken, a configuration value that must exist or an invariant you just established. Neither is right for a value that comes from a user, a file or the network. A more complete treatment of the panic is in called Option::unwrap() on a None value.

Methods that replace match

A match with a Some arm that rewraps its result and a None arm that returns None is a map written by hand. The standard library has the common shapes ready:

src/main.rsrust
use std::collections::HashMap;

fn main() {
    let mut ages: HashMap<&str, u32> = HashMap::new();
    ages.insert("ada", 36);

    // map: transform the inside, leave None alone
    let next_year = ages.get("ada").map(|age| age + 1);
    println!("{next_year:?}");

    // unwrap_or / unwrap_or_else: a default for None
    let guest = ages.get("bob").copied().unwrap_or(0);
    println!("{guest}");

    // and_then: chain steps that can each fail
    let digits: Option<u32> = Some("42").and_then(|s| s.parse().ok());
    println!("{digits:?}");

    // filter: keep Some only when the predicate holds
    let adult = ages.get("ada").copied().filter(|&a| a >= 18);
    println!("{adult:?}");

    // ok_or: turn None into an error so the caller can use ?
    let bob: Result<u32, String> = ages.get("bob").copied().ok_or("bob is unknown".to_string());
    println!("{bob:?}");

    // zip / or / xor
    println!("{:?}", Some(1).zip(Some("a")));
    println!("{:?}", None.or(Some(9)));
}
Text
Some(37)
0
Some(42)
Some(36)
Err("bob is unknown")
Some((1, "a"))
Some(9)
  • map transforms the inside and leaves None alone: the age 36 became Some(37).
  • unwrap_or(default) supplies a fallback for None; use unwrap_or_else with a closure when the default is expensive to build. The missing user bob became 0.
  • and_then chains a step that itself returns an Option. Here, parsing a string that might fail gives Some(42). This is flat_map in other languages.
  • filter keeps a Some only when the predicate is true.
  • ok_or converts None into an Err, which is how an absent value becomes an error the caller can propagate with ?. The Result side of that is in Result explained.
  • zip pairs two options into one, Some only if both are, and or supplies another option when the first is None.

The choice between match and these methods is about readability. One level of transformation reads better as map; three nested transformations with different fallbacks read better as a match with a named variable per step.

The question mark on Options

If a function itself returns Option, ? unwraps a Some and returns None from the function at the first None:

src/main.rsrust
struct User {
    name: &'static str,
    manager: Option<u32>,
}

fn user(id: u32) -> Option<User> {
    match id {
        1 => Some(User { name: "ada", manager: Some(2) }),
        2 => Some(User { name: "grace", manager: None }),
        _ => None,
    }
}

// Each `?` returns None from the function if the value is None.
fn managers_name(id: u32) -> Option<&'static str> {
    let manager_id = user(id)?.manager?;
    Some(user(manager_id)?.name)
}

fn main() {
    println!("{:?}", managers_name(1));
    println!("{:?}", managers_name(2));
    println!("{:?}", managers_name(9));
}
Text
Some("grace")
None
None

managers_name(1) finds user 1, then their manager id 2, then user 2's name, grace. For user 2 the first ? succeeds and the second, on manager, hits None and the function returns None immediately. For an unknown user, the very first ? returns. Compare that with the version that would nest three match expressions: the ? form reads as the happy path, with each potential absence a single character. The same operator works on Result in functions that return Result, covered in error handling, and you cannot mix the two in one function without converting with ok_or.

Borrowing and moving inside an Option

Options own their contents, so working with them hits the same ownership rules as anything else. Three methods deal with it:

src/main.rsrust
fn main() {
    let mut slot: Option<String> = Some("job-17".to_string());

    // as_ref: look without moving
    println!("{}", slot.as_ref().map(|s| s.len()).unwrap_or(0));
    println!("{slot:?}");

    // take: move the value out and leave None behind
    let job = slot.take();
    println!("{job:?} / {slot:?}");

    // replace and get_or_insert_with
    let cache: &mut String = slot.get_or_insert_with(|| "default".to_string());
    cache.push_str("!");
    println!("{slot:?}");
}
Text
6
Some("job-17")
Some("job-17") / None
Some("default!")

as_ref() turns &Option<String> into Option<&String>, so you can look at the string (here, its length, 6) without moving it out. take() moves the value out and leaves None behind, which is how you pull a job out of a slot or an optional field out of a struct that you hold by &mut. get_or_insert_with fills the slot if it is empty and returns a mutable reference to what is inside, so the final line shows Some("default!") after the push. These matter most for struct fields of type Option<T>; ownership explains why you cannot simply move a field out of a borrowed struct.

It costs nothing extra

You might expect wrapping every optional value in an enum to be expensive. For many types it is free:

src/main.rsrust
use std::mem::size_of;

fn main() {
    println!("&u8:            {}", size_of::<&u8>());
    println!("Option<&u8>:    {}", size_of::<Option<&u8>>());
    println!("Box<u64>:       {}", size_of::<Box<u64>>());
    println!("Option<Box<u64>>: {}", size_of::<Option<Box<u64>>>());
    println!("u32:            {}", size_of::<u32>());
    println!("Option<u32>:    {}", size_of::<Option<u32>>());
    println!("std::num::NonZeroU32:         {}", size_of::<std::num::NonZeroU32>());
    println!("Option<NonZeroU32>:           {}", size_of::<Option<std::num::NonZeroU32>>());
}
Text
&u8:            8
Option<&u8>:    8
Box<u64>:       8
Option<Box<u64>>: 8
u32:            4
Option<u32>:    8
std::num::NonZeroU32:         4
Option<NonZeroU32>:           4

A reference can never be null, so None can be represented as the all-zero pointer, and Option<&u8> is the same 8 bytes as &u8. The same goes for Box, and for NonZeroU32, a u32 that excludes zero: Option<NonZeroU32> is 4 bytes. A plain u32 uses all its bit patterns, so Option<u32> needs room for a tag and doubles to 8 bytes. This is the compiler's niche optimisation, and it is why Rust code can use Option freely without paying for it.

Habits

  • Return Option from a function that can legitimately find nothing, and Result when the reason matters.
  • Handle None at the boundary where you know what it means, and pass the Option along until then.
  • Use expect with a reason instead of bare unwrap.
  • Prefer map, and_then and ? to a match that only rewraps.
  • Do not use Option<bool> for three-state logic; a three-variant enum has names.

When Option is not the right type

If the absence has a cause the caller should know about (file not found, permission denied, parse failure), None throws that information away, and Result<T, E> is the right type. If a value is always present after construction but is set later, a builder or a state enum is cleaner than a field that is Option everywhere and unwrapped everywhere. And a Vec<T> already represents "zero or more", so a function returning Option<Vec<T>> where None and an empty vector mean the same thing is making callers handle two empty cases.

Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.