11.3Option and the end of null

Last updated June 13, 2026

Most languages have a special value that means "nothing here": null, nil, None, nullptr. It seems harmless, even necessary. It is, in fact, the source of an enormous share of the world's software crashes, and Rust doesn't have it. In its place is a data-carrying enum (lesson 11.2) that turns "there might be nothing here" into a fact the compiler refuses to let you ignore.

The billion-dollar mistake

Tony Hoare, who introduced null references into a language in 1965, later called it his "billion-dollar mistake":

I call it my billion-dollar mistake. It was the invention of the null reference in 1965... This has led to innumerable errors, vulnerabilities, and system crashes, which have probably caused a billion dollars of pain and damage in the last forty years.

The problem isn't the idea of absence; programs genuinely need to express "no value here" (the user typed nothing, the search found no match, the list is empty). The problem is that in most languages, any value of any type can secretly be null, and the compiler doesn't make you check. You write customer.email.length, the email is null, and the program crashes at runtime, in production, far from where the null came from. The type said email, it lied, and nobody found out until a user did.

Option makes absence a type

Rust's fix is to make absence visible in the type. A value that might be missing has type Option<T>, an enum with exactly two variants:

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

Some(T) carries a value of some type T; None carries nothing and means "absent." The <T> is a placeholder for whatever type the value is: Option<i32> is "maybe an i32," Option<String> is "maybe a String." (That <T> is generics, chapter 17's subject; for now read Option<i32> as one word meaning "an i32 that might not be there.") Option is so fundamental that it's in scope everywhere automatically; you write Some(5) and None without any imports.

fn main() {
    let some_number: Option<i32> = Some(5);
    let nothing: Option<i32> = None;

    println!("{some_number:?}");
    println!("{nothing:?}");
}
Some(5)
None

Here's the crucial part. An Option<i32> is not an i32. They are different types, and the compiler will not let you use one as the other:

fn main() {
    let x: i32 = 5;
    let y: Option<i32> = Some(5);
    let sum = x + y;
}
error[E0277]: cannot add `Option<i32>` to `i32`
 --> src/main.rs:4:17
  |
4 |     let sum = x + y;
  |                 ^ no implementation for `i32 + Option<i32>`

This error is the whole idea in one transcript. You cannot accidentally use a maybe-absent value as if it were definitely present, because the types don't match. To add y, you must first deal with the possibility that it's None. The compiler has moved the null check from "something you hope you remembered" to "something the program won't compile without."

Key insight

In most languages, every value is secretly nullable and the compiler trusts you to check. In Rust, a value is non-null by default, and the only way to express "might be absent" is Option<T>, which the compiler then forces you to unwrap before use. The billion-dollar mistake was making absence invisible. Option makes it a type you can see and the compiler can count.

Getting the value out: match

Since Option<T> is an enum, you handle it the way you handle any enum: with match (lesson 11.2). This forces you to write what happens in both cases, present and absent:

fn describe(maybe_age: Option<u32>) -> String {
    match maybe_age {
        Some(age) => format!("age is {age}"),
        None => String::from("age unknown"),
    }
}

fn main() {
    println!("{}", describe(Some(30)));
    println!("{}", describe(None));
}
age is 30
age unknown

The Some(age) arm only runs when there's a value, and it binds that value to age so you can use it safely. The None arm handles absence. There is no path through this code where you touch a missing value, and the compiler checked that by exhaustiveness. The crash that null causes simply has nowhere to happen.

unwrap and expect, explained at last

Way back in lesson 1.6 you wrote .expect("...") after read_line on a trust-us IOU, and you've used it since without a full explanation. Here it is. read_line and parse (lesson 5.6) return values that might have failed, and Option's cousin Result (chapter 12) wraps them the same way Option wraps maybe-absence. Option itself has the two methods you've been calling:

unwrap() says "I'm certain this is Some; give me the value, and if it's None, crash." expect("message") does the same but lets you supply the panic message, which is why lesson 3.x called expect's text "your words at the crash site."

fn main() {
    let x: Option<i32> = Some(5);
    println!("{}", x.unwrap());        // 5

    let y: Option<i32> = None;
    println!("{}", y.unwrap());        // panics
}
5

thread 'main' panicked at src/main.rs:6:22:
called `Option::unwrap()` on a `None` value
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

So unwrap and expect are the escape hatch: they convert "maybe absent" into "definitely present, or a panic." They're convenient and they're a trap, in equal measure. Every unwrap is a place your program will crash if you're wrong about None never happening.

Best practice

Prefer match (or the lighter tools in lesson 11.6) over unwrap/expect for Options that can really be None. Reserve unwrap for cases where None is genuinely impossible, and reach for expect over unwrap when you do use it, so the crash explains why you believed it couldn't happen. Chapter 12 returns to this judgment in full for Result.

A taste of the convenience methods

Writing a full match for every Option would be tiring, so Option carries dozens of methods for common patterns. A few you'll use constantly: unwrap_or(default) gives the value or a fallback; is_some() and is_none() ask which variant it is; map(...) transforms the inner value if present and does nothing if absent.

fn main() {
    let maybe: Option<i32> = None;
    println!("{}", maybe.unwrap_or(0));        // 0, the fallback
    println!("{}", Some(10).unwrap_or(0));     // 10, the value
}
0
10

unwrap_or is the safe, no-panic way to say "use this if there's nothing." These methods are why Option feels light in practice despite the strictness: the common cases each have a one-word method, and the full match is there when you need every case spelled out. Lesson 11.6 adds if let for the "do something only if Some" case.

Quiz time

Question #1

Why won't this compile, and what does the refusal prevent?

fn main() {
    let maybe: Option<i32> = Some(7);
    let doubled = maybe * 2;
    println!("{doubled}");
}
Show solution

Option<i32> is not i32, so multiplying it by 2 has no implementation (E0277). The refusal forces you to handle the None case before using the value, which is exactly the null-check that other languages let you skip. Fix by unwrapping safely, e.g. maybe.unwrap_or(0) * 2, or with a match.

Question #2

Write a function first_char(s: &str) -> Option<char> that returns the first character of a string, or None if the string is empty. Then call it on "hi" and on "", printing both results with {:?}.

Show solution
fn first_char(s: &str) -> Option<char> {
    s.chars().next()
}

fn main() {
    println!("{:?}", first_char("hi"));   // Some('h')
    println!("{:?}", first_char(""));     // None
}

s.chars().next() already returns Option<char>: the standard library uses Option precisely because "the next character" might not exist. Returning Option<char> makes the empty case part of the function's type, so every caller is forced to consider it.

Question #3

What does .unwrap() do on a None, and when is it appropriate to use it?

Show solution

On None, unwrap() panics (called Option::unwrap() on a None value). It's appropriate only when None is genuinely impossible at that point, or in quick throwaway code and tests. For Options that can really be absent, handle both cases with match, if let, or a method like unwrap_or. When you do unwrap, expect("why this can't be None") documents your reasoning at the crash site.

match has been doing the heavy lifting for three lessons on a need-to-know basis. The next lesson finally gives match its full treatment: binding patterns, ranges, multiple patterns, guards, and the @ operator.