DaleSchool

Understanding Option: When a Value Might Not Exist

Intermediate20min

Learning Objectives

  • Understand that Option<T> consists of Some(T) and None
  • Handle Option with match/if let/unwrap_or()/map()
  • Choose safe alternatives over unwrap()
  • Use Option as a function return value

Working Code

Other languages use null when a value doesn't exist — and then suffer from the infamous NullPointerException. Rust has no null. Instead, Option<T> expresses "a value may or may not exist."

fn main() {
    let fruits = vec!["apple", "banana", "grape"];

    let first = fruits.get(0);   // Some("apple")
    let fourth = fruits.get(10); // None

    println!("First: {:?}", first);
    println!("Fourth: {:?}", fourth);
}

vec.get() returns Option<&T>. If the index is in range, you get Some(value); otherwise, None. Unlike fruits[10] which panics, .get(10) safely returns None.

Try It Yourself

  1. Print the result of fruits.get(2).
  2. Try fruits[10] instead. What error do you see?
  3. Compare it with fruits.get(10). Can you see why None is better than a panic?

"Why?" — Think of It Like an Inventory Check

Think of Option as an inventory check at a store:

  • Some(item) — In stock! Here's the item
  • None — Out of stock! Nothing here
enum Option<T> {
    Some(T),  // value exists
    None,     // no value
}

Option<T> is just an enum from Lesson 13! It's defined in the standard library, and Some and None are available without any imports.

Handling With match

The most fundamental approach. Since match must be exhaustive, it's inherently safe:

fn find_discount(item: &str) -> Option<u32> {
    match item {
        "apple" => Some(10),
        "banana" => Some(20),
        _ => None,
    }
}

fn main() {
    let item = "apple";

    match find_discount(item) {
        Some(percent) => println!("{item}: {percent}% off!"),
        None => println!("{item}: no discount"),
    }
}

if let — When You Only Care About Some

If you only want to handle the Some case, if let is more concise:

fn main() {
    let name: Option<&str> = Some("Alice");

    if let Some(n) = name {
        println!("Hello, {n}!");
    }
    // If None, nothing happens
}

unwrap_or() — Provide a Default

Specify a fallback value for the None case:

fn main() {
    let port: Option<u16> = None;
    let actual_port = port.unwrap_or(8080);
    println!("Port: {actual_port}"); // Port: 8080

    let custom: Option<u16> = Some(3000);
    let actual = custom.unwrap_or(8080);
    println!("Port: {actual}"); // Port: 3000
}

There's also unwrap_or_default(), which uses the type's default value (0, "", false, etc.):

fn main() {
    let count: Option<i32> = None;
    println!("Count: {}", count.unwrap_or_default()); // Count: 0

    let name: Option<String> = None;
    println!("Name: '{}'", name.unwrap_or_default()); // Name: ''
}

map() — Transform Only When Present

Use map() to transform the value inside Some. If it's None, it stays None:

fn main() {
    let name: Option<&str> = Some("rust");
    let upper = name.map(|n| n.to_uppercase());
    println!("{:?}", upper); // Some("RUST")

    let empty: Option<&str> = None;
    let upper = empty.map(|n| n.to_uppercase());
    println!("{:?}", upper); // None
}

and_then() — Chaining When the Transform Returns Option

Use map() when the transform returns a plain value. Use and_then() when the transform itself returns an Option:

fn parse_port(s: &str) -> Option<u16> {
    s.parse::<u16>().ok()
}

fn main() {
    let input: Option<&str> = Some("8080");
    let port = input.and_then(parse_port);
    println!("{:?}", port); // Some(8080)

    let bad: Option<&str> = Some("abc");
    let port = bad.and_then(parse_port);
    println!("{:?}", port); // None
}

Using map() here would produce Option<Option<u16>>. and_then() flattens it to Option<u16>.

Real Example: User Lookup

Combining multiple methods leads to clean code:

struct User {
    name: String,
    email: Option<String>,
}

fn find_user(users: &[User], name: &str) -> Option<&User> {
    users.iter().find(|u| u.name == name)
}

fn main() {
    let users = vec![
        User { name: "Alice".into(), email: Some("alice@example.com".into()) },
        User { name: "Bob".into(), email: None },
    ];

    // Find user, then get email if it exists
    let email = find_user(&users, "Alice")
        .and_then(|u| u.email.as_deref())
        .unwrap_or("no email");

    println!("Alice's email: {email}");

    let email = find_user(&users, "Carol")
        .and_then(|u| u.email.as_deref())
        .unwrap_or("no email");

    println!("Carol's email: {email}");
}

Why Is unwrap() Dangerous?

unwrap() panics on None. In Lesson 16 you learned that Result's unwrap() is dangerous — Option is the same:

fn main() {
    let value: Option<i32> = None;
    // value.unwrap(); // panic: called `Option::unwrap()` on a `None` value
}

| Method | On None | Use case | | --------------------- | -------------------- | ------------------------------------ | | unwrap() | panic! | Test code, guaranteed-Some scenarios | | unwrap_or(default) | Returns default | When you have a fallback value | | unwrap_or_default() | Returns type default | When 0, "", etc. are acceptable | | expect("message") | Panic with message | Debugging |

As you get comfortable, chaining map(), and_then(), and unwrap_or() becomes second nature. It feels awkward at first, but reading Rust code will make it click!

Exercise 1 (easy). Complete the function. It finds and returns the first even number in a vector.

fn first_even(numbers: &[i32]) -> Option<i32> {
    // Complete this
    // Hint: use a for loop and if to find an even number, return Some(n)
    // Return None if not found
}

fn main() {
    let nums = vec![1, 3, 4, 7, 8];
    println!("{:?}", first_even(&nums)); // Some(4)

    let odds = vec![1, 3, 5];
    println!("{:?}", first_even(&odds)); // None
}

Exercise 2 (medium). Remove the unwrap() calls and replace them with safe alternatives.

fn main() {
    let config = vec![
        ("host", "localhost"),
        ("port", "8080"),
    ];

    // Fix these without unwrap()!
    let host = config.iter()
        .find(|(k, _)| *k == "host")
        .unwrap()
        .1;

    let timeout = config.iter()
        .find(|(k, _)| *k == "timeout")
        .unwrap() // panic here!
        .1;

    println!("host: {host}, timeout: {timeout}");
}

Hint: Combine map() and unwrap_or().

Exercise 3 (challenge). Use and_then() to handle nested Option values.

fn get_area_code(phone: Option<&str>) -> Option<&str> {
    // If phone is Some and contains "-", return the part before "-" (area code)
    // Otherwise return None
    // Hint: use and_then() and split_once()
}

fn main() {
    println!("{:?}", get_area_code(Some("02-1234-5678"))); // Some("02")
    println!("{:?}", get_area_code(Some("01012345678")));   // None
    println!("{:?}", get_area_code(None));                  // None
}

Q1. Which statement about Option<T> is correct?

  • A) It returns null when there's no value
  • B) It uses Some(T) and None to indicate presence or absence of a value
  • C) It terminates the program when an error occurs
  • D) It is exactly the same as Result<T, E>

Q2. What does this code print?

fn main() {
    let v = vec![10, 20, 30];
    let value = v.get(5).unwrap_or(&0);
    println!("{value}");
}
  • A) 30
  • B) None
  • C) 0
  • D) The program panics

Q3. What is the difference between map() and and_then()?

  • A) No difference
  • B) map() is for transforms returning plain values; and_then() is for transforms returning Option
  • C) map() only works on None
  • D) and_then() always causes a panic