Skip to lesson

learningrust.org / intermediate / 07-option-and-result · lesson 10 of 26

TL;DR

Master Rust Option and Result types for handling absence and errors with combinators, the ? operator, and error propagation

Key concepts

  • Rust Option Result
  • Rust error types
  • Rust Option type
  • Rust Result type
  • Rust question mark operator

Option and Result

Two enums sit at the heart of almost every Rust program you will ever write: Option<T> and Result<T, E>. They replace null pointers, and the unchecked exceptions a program is expected to recover from, with types the compiler enforces. Once you internalise both, you gain a superpower: the compiler tells you exactly where something might be absent or might fail, and refuses to compile until you have handled it.

What You Already Know

By the end of this lesson you will write three functions whose return types are the specification: one that says a value might be absent, one that says an operation might fail and names how, and one that turns the first into the second. The part that takes practice is not the syntax but the choosing — Option and Result look interchangeable until you have to explain to a caller what your function promises, and then the difference is the whole point.

You arrive here able to define an enum whose value is exactly one variant at a time (Structs & Enums) and to take it apart exhaustively with match (Pattern Matching). That is all Option and Result are: two ordinary two-variant enums from the standard library, with no magic in them. Everything the compiler does for you here is the exhaustiveness rule you already met, applied to a type you did not have to write.

The capstone leans on both, in the two roles this lesson separates. Its parser returns a Result because a malformed line is a failure that must say what went wrong, and its lookups return an Option because a task that is not in the list is an absence with nothing to explain.

Option — Values That May Not Exist

Option<T> models the idea of "a value that is either present or not". The standard library defines it as:

enum Option<T> {
    None,    // nothing is here
    Some(T), // a value is present
}

Returning None is Rust's safe alternative to returning null. Because the type system tracks whether a value is wrapped in Option, you can never accidentally use a "null" value without checking it first.

Basic Option Usage

fn find_first_even(numbers: &[i32]) -> Option<i32> {
    for &n in numbers {
        if n % 2 == 0 {
            return Some(n);
        }
    }
    None
}

fn main() {
    let odds  = vec![1, 3, 5, 7];
    let mixed = vec![1, 3, 4, 6];

    match find_first_even(&odds) {
        Some(n) => println!("Found even number: {}", n),
        None    => println!("No even numbers in the list"),
    }

    match find_first_even(&mixed) {
        Some(n) => println!("Found even number: {}", n),
        None    => println!("No even numbers in the list"),
    }
}

match is the most explicit way to handle an Option, but Rust provides several concise combinators for common patterns.

Option Combinators

Combinators let you transform and chain Option values without nested match blocks. Before reaching for one, it is worth seeing what it replaces. Here is the long way, fully annotated:

fn lookup(key: &str) -> Option<&'static str> {
    match key {
        "host" => Some("  example.com  "),
        "port" => Some("8080"),
        _ => None,
    }
}

fn main() {
    // Stage 1: the full match. Everything the combinators do is here,
    // written out — which is why it is worth reading once before shortening it.
    let host = match lookup("host") {
        // The Some arm's job is to TRANSFORM the value it found.
        Some(raw) => raw.trim().to_string(),
        // The None arm's job is to SUPPLY something instead. Notice these are
        // two different jobs; the combinators below split them apart.
        None => String::from("(unset)"),
    };
    println!("host = {host}");

    let missing = match lookup("scheme") {
        Some(raw) => raw.trim().to_string(),
        None => String::from("(unset)"),
    };
    println!("scheme = {missing}");
}

It prints host = example.com and scheme = (unset). Now the same program with the two jobs named rather than written out. The output is identical — the reason to prefer this version is not brevity but that each combinator has exactly one job and says so:

fn lookup(key: &str) -> Option<&'static str> {
    match key {
        "host" => Some("  example.com  "),
        "port" => Some("8080"),
        _ => None,
    }
}

fn main() {
    // Stage 2: the same two jobs, now named separately. map does the Some
    // arm's job and does NOTHING to a None; unwrap_or does the None arm's
    // job and does nothing to a Some. Neither can do the other's work.
    let host = lookup("host")
        .map(|raw| raw.trim().to_string())
        .unwrap_or(String::from("(unset)"));
    println!("host = {host}");

    let missing = lookup("scheme")
        .map(|raw| raw.trim().to_string())
        .unwrap_or(String::from("(unset)"));
    println!("scheme = {missing}");
}

The third stage is the build task at the end of this lesson, where you get the signatures and choose the combinators yourself. Keep the split in mind while reading the larger example below: every combinator here is doing one of the two arms' jobs, and knowing which tells you what it can and cannot do.

fn username_initials(name: &str) -> Option<String> {
    // Split on whitespace, collect first letters, return None if no words
    let initials: String = name
        .split_whitespace()
        .filter_map(|word| word.chars().next())
        .map(|c| c.to_ascii_uppercase())
        .collect();

    if initials.is_empty() { None } else { Some(initials) }
}

fn main() {
    let names = vec!["alice bob", "", "carol  dan  eve"];

    for name in &names {
        // map: transform the Some value, leaving None untouched
        let display = username_initials(name)
            .map(|i| format!("Initials: {}", i))
            .unwrap_or_else(|| String::from("(no name)"));

        println!("{:?} => {}", name, display);
    }

    // unwrap_or gives a default without panicking
    let score: Option<u32> = None;
    println!("Score: {}", score.unwrap_or(0));

    // and_then chains operations that each return an Option
    let raw: Option<&str> = Some("  42  ");
    let parsed: Option<u32> = raw
        .map(str::trim)
        .and_then(|s| s.parse().ok());
    println!("Parsed: {:?}", parsed);
}

Key combinators to know:

CombinatorWhat it does
map(f)Transform Some(x) to Some(f(x)); pass None through
and_then(f)Chain: call f(x) which itself returns an Option
unwrap_or(default)Extract the value or use the fallback
unwrap_or_else(f)Like unwrap_or but the fallback is lazily computed
filter(pred)Keep Some(x) only if pred(x) is true

The first two rows of that table are the ones people mix up, and the table alone will not fix it — the difference only becomes obvious when you look at the type that comes back. In the program below, chains A and B call the identical closure; the only difference is map versus and_then. Predict all four printed lines before running.

Predict

first_word returns an Option. Chain A feeds it to map, chain B feeds the same closure to and_then, and chain C uses map with a closure that returns a plain value. What exactly does each line print?

fn first_word(line: &str) -> Option<&str> {
  line.split_whitespace().next()
}

fn main() {
  let line = Some("42 apples");

  // Chain A: the closure returns an Option, and we used map.
  let a = line.map(|l| first_word(l));

  // Chain B: the same closure, but with and_then.
  let b = line.and_then(|l| first_word(l));

  // Chain C: map with a closure that returns a plain value, not an Option.
  let c = line.map(|l| l.len());

  println!("a = {:?}", a);
  println!("b = {:?}", b);
  println!("c = {:?}", c);
  println!("b unwrapped or fallback = {}", b.unwrap_or("(none)"));
}
Continue learning

a is Some(Some("42")), b is Some("42"), c is Some(9), and the last line prints 42. map takes whatever the closure returned and wraps it in a fresh Some — so a closure that already returns an Option gives you an Option inside an Option. and_then expects the closure to be fallible and uses its Option directly. Chain C is what map is for: l.len() is a plain usize, so wrapping it is exactly right. The short version: plain value out of the closure → map; another Option out of the closure → and_then. The same rule holds for Result, where and_then plays the same flattening role.

Result — Operations That Can Fail

Result<T, E> models a computation that either succeeds with a value of type T or fails with an error of type E:

enum Result<T, E> {
    Ok(T),  // success
    Err(E), // failure, with an error value
}

Any function that can fail should return Result instead of panicking. Callers are then forced by the compiler to decide what to do with errors.

Defining and Returning Results

Two things in the program below run ahead of where they are taught, so here is enough to read them.

The impl std::fmt::Display for ParseError block is a trait implementation — it teaches one type how to answer one specific question, and Display is the trait for "render yourself as text for a human". Writing it is what makes {} work on a ParseError in the final println!; without it only {:?} would, and that prints the debugging form. Traits get a lesson of their own later; for now, read the block as the definition of what this error looks like when printed, and notice that each variant gets to explain itself in its own words.

The ? at the end of the .parse() call is the other one, and it has a section immediately after this one. In short: ? means "if this is an Err, stop here and return that error to my caller; otherwise give me the value inside". It is the whole match-on-Err-and-return-early dance compressed to one character, and the .map_err(...) in front of it converts the error into this function's own error type on the way out. Read the section below for the constraint that comes with it, because it is the part people trip over.

#[derive(Debug)]
enum ParseError {
    Empty,
    InvalidFormat(String),
    OutOfRange(i32),
}

impl std::fmt::Display for ParseError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            ParseError::Empty             => write!(f, "input was empty"),
            ParseError::InvalidFormat(s)  => write!(f, "invalid format: {}", s),
            ParseError::OutOfRange(n)     => write!(f, "value {} is out of range (1–100)", n),
        }
    }
}

fn parse_score(input: &str) -> Result<i32, ParseError> {
    let trimmed = input.trim();
    if trimmed.is_empty() {
        return Err(ParseError::Empty);
    }
    let n: i32 = trimmed
        .parse()
        .map_err(|_| ParseError::InvalidFormat(trimmed.to_string()))?;
    if !(1..=100).contains(&n) {
        return Err(ParseError::OutOfRange(n));
    }
    Ok(n)
}

fn main() {
    let inputs = ["42", "", "abc", "200", " 77 "];
    for input in &inputs {
        match parse_score(input) {
            Ok(score) => println!("{:?} => score {}", input, score),
            Err(e)    => println!("{:?} => error: {}", input, e),
        }
    }
}

Notice the ? at the end of the .parse() call. If the parse fails, ? returns the Err early, after running map_err to convert the error type. This is the idiomatic Rust way to propagate errors.

The ? Operator

The ? operator is shorthand for "if this is an Err (or None), return it immediately; otherwise give me the inner value." It dramatically reduces boilerplate in functions that call multiple fallible operations in sequence:

use std::num::ParseIntError;

fn add_str_numbers(a: &str, b: &str) -> Result<i32, ParseIntError> {
    // ? propagates the error and returns early if parsing fails
    let x: i32 = a.trim().parse()?;
    let y: i32 = b.trim().parse()?;
    Ok(x + y)
}

fn main() {
    println!("{:?}", add_str_numbers("10", "32"));   // Ok(42)
    println!("{:?}", add_str_numbers("10", "oops")); // Err(...)
    println!("{:?}", add_str_numbers(" 5 ", " 5 ")); // Ok(10)
}

Read that sentence about ? carefully, because it hides a constraint that trips up almost everyone: ? can only return early from the function it is written in, so the function's return type has to be able to hold what ? is bailing out with. A ? on an Option inside a function returning Result does not compile — there is no error value for it to produce. A ? on a Result<_, E1> inside a function returning Result<_, E2> only compiles if E2 can be built from E1, which is why add_str_numbers above declares its error type as ParseIntError, precisely the error .parse() produces.

? works identically for Option inside functions that return Option.

Recall

Without scrolling up: in Pattern Matching you learned that a match must be exhaustive — the compiler rejects it unless every possible value of the matched type is covered. Apply that to Option and Result. What does exhaustiveness actually buy you here, compared with a language that uses null?

Continue learning

The point of exhaustiveness here is that absence and failure are ordinary enum variants, so the rule you already know — a match must cover every variant — forces the missing case into the open at the moment you read the value. You can handle it, default it with unwrap_or, or defer it to your caller with ?, but you cannot silently skip it. Every combinator in this lesson is shorthand for a match you would otherwise write out: unwrap_or is a match with a default arm, map is a match that leaves None untouched, and ? is a match that hands the failing arm back to the caller.

The dangerous version of "handling" a failure is one that handles it by erasing it. The next program does exactly that, and the giveaway is subtle — it compiles without a warning and prints a total that looks right. Commit a hypothesis about which line throws the information away before you change anything:

Debug

total_recorded should skip blank entries but report a malformed entry as an error. It compiles cleanly and its totals look plausible, but one of the two calls returns Ok when it should return Err. Say which line erases the failure and why, then fix it.

/// Total the scores that were recorded, ignoring blank entries.
/// A malformed entry is a data error and must NOT be silently counted as zero.
fn total_recorded(entries: &[&str]) -> Result<u32, String> {
  let mut total = 0;
  for entry in entries {
      let trimmed = entry.trim();
      if trimmed.is_empty() {
          continue;
      }
      total += trimmed.parse::<u32>().ok().unwrap_or(0);
  }
  Ok(total)
}

fn main() {
  let good = ["10", "", "32", "  "];
  let bad = ["10", "oops", "32"];

  println!("good -> {:?}", total_recorded(&good));
  println!("bad  -> {:?}", total_recorded(&bad));

  assert_eq!(total_recorded(&good), Ok(42), "blank entries should be skipped");
  assert!(
      total_recorded(&bad).is_err(),
      "a malformed entry must be reported as an error, not counted as zero"
  );
  println!("All checks passed.");
}

Expected output:

good -> Ok(42)
bad  -> Err("'oops' is not a number")
All checks passed.
Continue learning

The line that erases the failure is total += trimmed.parse::<u32>().ok().unwrap_or(0);. parse returns a Result; .ok() converts it to an Option and throws the error away; .unwrap_or(0) then turns the resulting None into a plain zero. So "oops" is silently counted as 0 and the function reports Ok(42) for data it should have rejected — the same total as the good input, which is exactly why this survives review. The fix is to propagate rather than erase: total += trimmed.parse::<u32>().map_err(|_| format!("'{}' is not a number", trimmed))?;. Reach for .ok() only when you have genuinely decided the error carries no information you need — pairing it with unwrap_or converts a failure into a believable number, which is the worst of the three outcomes.

Converting Between Option and Result

Because Option and Result model related concepts, the standard library provides easy conversions:

// Option → Result
let opt: Option<i32> = Some(42);
let res: Result<i32, &str> = opt.ok_or("missing value");

// Result → Option (discards the error)
let res2: Result<i32, &str> = Ok(42);
let opt2: Option<i32> = res2.ok();

ok_or is particularly useful when you want to treat a missing value as an error in a Result-returning function.

Try It Yourself

Reading about combinators is not the same as reaching for the right one under your own name. This is a build task: a small program that reports its own pass/fail. Three functions are stubbed out — one returns an Option, one returns a Result with two distinct error messages, and one collapses that Result into a plain number with a fallback. Run the starter as-is and it fails immediately, naming the first check that did not pass. Implement each until every check passes and it prints All checks passed.

The three signatures are the shape of every real config reader: absence is an Option, failure is a Result that says what failed, and a caller who does not care gets a default. The error strings are asserted exactly, so read the two format! templates in the comments carefully — a mismatched message is a failing check, not a stylistic difference.

Build

Finish the build. Three functions are stubbed out and the checks below them fail until each one returns the right value. Run it as-is to see which check fails first, decide what that function is missing, then implement all three until it prints 'All checks passed.' The checks run top to bottom, so the first failure you see is TODO 1 — implement it first, then work down. Do not change the signatures: which of Option, Result and a plain u32 each function returns is the exercise.

// A tiny config reader. The config is a list of "key=value" lines.

// TODO 1: find the value for key, or None if no line has that key.
//   Split each line once on '=', compare the key half, return the value half
//   as an owned String. lookup(&["a=1"], "a") -> Some("1".to_string())
//   Hint: str has a split_once('=') method returning Option<(&str, &str)>.
fn lookup(lines: &[&str], key: &str) -> Option<String> {
  let _ = lines;
  let _ = key;
  None
}

// TODO 2: return the value for key parsed as a u32, or an Err naming
//   the failure. The two messages are asserted EXACTLY:
//     missing key -> format!("missing key '{}'", key)
//     bad number  -> format!("key '{}' is not a number: '{}'", key, raw)
//   Reach for lookup, ok_or_else and map_err. No unwrap, no panic.
fn read_number(lines: &[&str], key: &str) -> Result<u32, String> {
  let _ = lines;
  let _ = key;
  Ok(0)
}

// TODO 3: return the value for key if present and parseable, else fallback.
//   This one can never fail — it returns a plain u32.
//   One call to read_number plus one combinator is all it takes.
fn read_number_or(lines: &[&str], key: &str, fallback: u32) -> u32 {
  let _ = lines;
  let _ = key;
  let _ = fallback;
  0
}

// --- Build checks: these must all pass. Do not edit below this line. ---
fn main() {
  let config = ["port=8080", "retries=3", "host=localhost", "timeout=abc"];

  assert_eq!(lookup(&config, "host"), Some("localhost".to_string()), "lookup should find an existing key");
  assert_eq!(lookup(&config, "nope"), None, "lookup should return None for a missing key");

  assert_eq!(read_number(&config, "port"), Ok(8080), "read_number should parse a present numeric value");
  assert_eq!(
      read_number(&config, "nope"),
      Err("missing key 'nope'".to_string()),
      "a missing key should be an Err naming the key"
  );
  assert_eq!(
      read_number(&config, "timeout"),
      Err("key 'timeout' is not a number: 'abc'".to_string()),
      "an unparseable value should be an Err naming key and value"
  );

  assert_eq!(read_number_or(&config, "retries", 1), 3, "a good value wins over the fallback");
  assert_eq!(read_number_or(&config, "timeout", 30), 30, "a bad value falls back");
  assert_eq!(read_number_or(&config, "nope", 7), 7, "a missing key falls back");

  println!("All checks passed.");
  println!("host: {:?}", lookup(&config, "host"));
  println!("port: {:?}", read_number(&config, "port"));
  println!("timeout: {:?}", read_number(&config, "timeout"));
  println!("timeout or 30: {}", read_number_or(&config, "timeout", 30));
}

Expected output:

All checks passed.
host: Some("localhost")
port: Ok(8080)
timeout: Err("key 'timeout' is not a number: 'abc'")
timeout or 30: 30
Continue learning

Once it passes, try two variations and predict each before running:

  1. Use ? on an Option in a Result function. In read_number, replace the whole ok_or_else(...)? call with a bare let raw = lookup(lines, key)?;. Predict what happens before running. It does not compile: error[E0277]: the ? operator can only be used on Results, not Options, in a function that returns Result, and rustc even suggests "use .ok_or(...)? to provide an error compatible with Result<u32, String>". ? returns early from this function, so what it bails out with must fit this function's return type — the exact constraint from the ? section above.
  2. Swap unwrap_or for unwrap_or_default. In read_number_or, change the body to read_number(lines, key).unwrap_or_default(). Predict which check breaks before running. It compiles (with a warning that fallback is now unused) and fails the check a bad value falls back, with left: 0, right: 30 — unwrap_or_default supplies u32::default(), which is 0, not the caller's fallback. A default that is silently zero is how a config bug becomes a production incident.

Reading the Errors These Types Produce

Option and Result generate a small family of diagnostics you will meet constantly, and two of them are worth learning to read properly because they point at different things.

The first is what happens when you forget that a fallible function hands you a wrapper rather than a value:

error[E0369]: cannot add `{integer}` to `Result<i32, ParseIntError>`
 --> /tmp/main.rs:7:22
  |
7 |     println!("{}", n + 1);
  |                    - ^ - {integer}
  |                    |
  |                    Result<i32, ParseIntError>
  |
note: `Result<i32, ParseIntError>` does not implement `Add<{integer}>`
 --> /rustc/8bab26f4f68e0e26f0bb7960be334d5b520ea452/library/core/src/result.rs:557:0
  |
  = note: `Result<i32, ParseIntError>` is defined in another crate

Read the span labels before the message. Under the offending expression rustc has written each operand's type beneath it — Result<i32, ParseIntError> on the left, {integer} on the right — and that pair is the diagnosis: you have a wrapper where you expected the thing inside. The message name for the error, "cannot add", is the least useful part; it describes the symptom. The note: confirms the mechanism (no Add implementation exists for that type) and is not asking you to write one. What this error means, essentially always, is that a ?, a match, or an unwrap_or is missing somewhere above.

The second is the ? constraint from the section above, and it is unusual in that the fix is written into the span itself:

error[E0277]: the `?` operator can only be used on `Result`s, not `Option`s, in a function that returns `Result`
 --> /tmp/main.rs:2:32
  |
1 | fn first_char(line: &str) -> Result<char, String> {
  | ------------------------------------------------- this function returns a `Result`
2 |     let c = line.chars().next()?;
  |                                ^ use `.ok_or(...)?` to provide an error compatible with `Result<char, String>`

Three things to notice as a routine. The --> points at the ?, one character wide — the caret marks where the rule was broken, not where the mistake was made; the mistake is arguably the signature on line 1. rustc knows this, which is why it gives line 1 its own span labelled this function returns a Result: when a diagnostic underlines two lines, it is telling you the conflict is between them, and the fix is a choice about which one to change. And the label on the caret, use .ok_or(...)?, is a suggestion of the useful kind — it adds a conversion rather than papering over a design decision — but it is still only one of the two available fixes. The other is to change the return type to Option<char>, which is right if absence is genuinely not an error worth describing. The compiler can tell you the two are inconsistent; it cannot tell you which one you meant.

Both of these are compile errors, and that is the point of the lesson stated as a diagnostic: the failure modes of Option and Result are things you read at build time. The one that is not a compile error is .unwrap() on a None or an Err, which is a runtime panic with no -->, no spans and no help: at all — just a message, a file and a line. When you see that, the useful question is not what the panic says but which unwrap in the file produced it.

Put the Order Back

Arrange the code

These five lines pull the port number out of a host string, double it, and hand it back wrapped in an Option. Put them in the order that runs — then answer what makes this chain different from an ordinary sequence of lets: two of these lines can end the function early without any return keyword, so say which two, and what the function's return type has to be for that to be allowed at all.

  1. let after_colon = trimmed.split(':').nth(1)?;
  2. let doubled = port * 2;
  3. let trimmed = raw.trim();
  4. let port: u32 = after_colon.parse().ok()?;
  5. Some(doubled)
Continue learning

Carrying the Idea Across

Transfer

Rust has no null: a value that might be missing has the type Option<T>, and the compiler will not let you use the T without dealing with the None case first. TypeScript with strictNullChecks turned on reaches for the same goal from the opposite direction — null and undefined stay in the language, but they are removed from every other type unless a union explicitly puts them back. Which statement names what genuinely transfers between the two?

Continue learning

Key Takeaways

  • Option<T> replaces null: use Some(value) or None to represent optional data.
  • Result<T, E> carries recoverable failure in the return type: use Ok(value) or Err(error) for fallible operations, and leave panic! for the unrecoverable case.
  • match handles both types exhaustively; combinators like map, and_then, and unwrap_or keep code concise.
  • The ? operator propagates errors (or None) early, eliminating most boilerplate error-forwarding code.
  • Convert between the two with .ok() (Result → Option) and .ok_or(e) (Option → Result).
  • Prefer descriptive custom error types over plain String errors in library code.
  • Never use .unwrap() in production code paths; always handle or propagate errors explicitly.

Pro Tip: Reach for and_then when you need to chain multiple Option- or Result-returning operations. It reads like a pipeline: each step only runs if the previous one succeeded, and the first failure short-circuits the whole chain — giving you the same guarantees as the ? operator but as a functional expression you can embed in an iterator chain or assignment.

Next Steps

Now that you understand Option and Result, you're ready to learn about error handling in depth — including custom error types, the ? operator in practice, and strategies for robust error management.

Two-tier handoff: this document is the complete reading surface. Continue learning for stateful practice, progress, and real sandbox execution.