13.1 Option<T>: Some(T) and None
Right, let’s talk about Option<T>. This is the moment we collectively decided to stop pretending null was a good idea. You’ve met null, right? The billion-dollar mistake? It’s a value that isn’t a value, a hole in your type system that you can fall into at runtime, causing your program to explode in a fiery NullReferenceException. It’s like a post-it note that says “this might be nothing,” but the post-it can fall off, and you’ll never know until you try to use the thing that isn’t there.
Option<T> is our formal, type-safe, compiler-enforced way of saying, “Hey, this operation might not give you a thing.” It forces you to handle both possibilities at compile time. The compiler becomes your brilliant, pedantic friend who won’t let you forget to check for null. It’s not a suggestion; it’s the law.
The Option<T> enum itself is deceptively simple. It has two variants:
enum Option<T> {
Some(T),
None,
}
That’s it. That’s the whole thing. It either contains a value of type T (Some(T)) or it doesn’t (None). The genius is that Option<T> is its own distinct type. An Option<String> is not a String. You can’t accidentally use one where the other is expected. The compiler will stop you. This is the core of its power.
Unwrapping (And Why You Usually Shouldn’t)
I know you’re going to do it. I’ve done it. We’ve all done it in a moment of haste. But let’s get it out of your system right now so you can learn why it’s a bad idea.
.unwrap() is the equivalent of you looking at an Option<T> and saying, “I am 100% certain this is a Some value, and if I’m wrong, I want the entire program to panic and die immediately.” It’s the “hold my beer” of error handling.
let some_value: Option<i32> = Some(42);
let extracted_value: i32 = some_value.unwrap(); // This is fine, prints 42
println!("The value is: {}", extracted_value);
let no_value: Option<i32> = None;
let disaster: i32 = no_value.unwrap(); // This will panic at runtime: thread 'main' panicked at 'called `Option::unwrap()` on a `None` value'
See? It’s a trap door. You use .unwrap() when you’re prototyping or when a situation truly is unrecoverable (though even then, expect() is better because it lets you leave a note for the poor soul who finds the crash). For virtually all other cases, it’s a code smell. The whole point of Option is to avoid this panic, not to create a new, more verbose way to have it.
Actually Handling the Option: match
This is the right way. The robust way. The way that makes the compiler sing. You use match to exhaustively handle every possible variant of the Option.
fn maybe_get_username(id: u32) -> Option<String> {
// Imagine this does a database lookup... sometimes it finds a user.
if id == 42 {
Some("ArthurDent".to_string())
} else {
None
}
}
let user_id = 42;
let username = maybe_get_username(user_id);
match username {
Some(name) => println!("Welcome back, {}!", name), // This branch runs for Some(T)
None => println!("User not found. Did you forget your towel?"), // This branch runs for None
}
The beauty of match is its exhaustiveness. The compiler will force you to handle the None case. Forget to write the None arm? Compiler error. This is the core guarantee that eliminates null dereferences. You cannot get to the value inside without first acknowledging and deciding what to do about its potential absence.
The Power of if let
Sometimes you only care about the Some case, and if it’s None, you just want to do nothing or bail out early. Writing a full match can feel clunky for this. Enter if let: the syntactic sugar for when you only want to match one pattern.
let some_option: Option<Vec<i32>> = Some(vec![1, 2, 3]);
// We only want to do something if it's Some and the vec isn't empty.
if let Some(numbers) = some_option {
if !numbers.is_empty() {
println!("The first number is {}", numbers[0]);
}
}
// If some_option was None, this whole block is silently skipped.
It’s incredibly useful for concisely peeking inside an Option without the ceremony of a full match when the None case requires no special action.
Chaining Operations with ?
You’ll often have functions that return Option<T> and need to call them one after another. The ? operator is the magic that makes this clean and readable. It’s a early return for None.
If the value before ? is Some(value), it unwraps the value and lets you continue.
If it’s None, it returns None from the entire function immediately.
struct User {
name: String,
address: Option<Address>,
}
struct Address {
street: Option<String>,
}
fn get_street_name(user: User) -> Option<String> {
// The ? operator does all the nested matching for us.
let address = user.address?; // If user.address is None, return None.
let street = address.street?; // If address.street is None, return None.
Some(street) // Now we have a value, wrap it back up in Some.
}
let user = User {
name: "Ford Prefect".to_string(),
address: Some(Address {
street: Some("Public Coast Road".to_string()),
}),
};
println!("Street is: {:?}", get_street_name(user)); // Prints Some("Public Coast Road")
Without the ? operator, this function would be a nightmare of nested match statements. The ? operator flattens it, making the happy path clear and obvious. It’s the workhorse for composing operations that might fail.