Rust Option vs Result: ?, blocks, closures, and type flow

Author

Andres Monge

Published

June 5, 2026

Option<T> and Result<T, E> are two of the most important shapes in Rust.

They are different, but they feel similar on purpose.

That shared shape is why methods like map(), and_then(), and ? feel consistent once you learn the pattern.

Option vs Result

This returns an Option because missing is normal here:

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

This returns a Result because parsing can fail with a real error:

fn parse_port(text: &str) -> Result<u16, std::num::ParseIntError> {
    text.parse::<u16>()
}

The short rule is:

  • use Option when “not found” or “not present” is an expected outcome
  • use Result when failure should carry an error

Read the Types Left to Right

Rust gets easier when you narrate the types as the chain changes.

use std::collections::HashMap;

let mut scores = HashMap::new();
scores.insert("ana", 91);
scores.insert("ben", 77);

let passed = scores
    .get("ana")
    .is_some_and(|score| *score >= 80);

Type flow:

  1. scores is HashMap<&str, i32>
  2. scores.get("ana") is Option<&i32>
  3. is_some_and(...) checks the Some case and returns bool

The important part is step 2.

HashMap::get returns a reference because the map still owns the value.

It does not give you Option<i32>. It gives you Option<&i32>.

That is why the closure receives &i32 and we write *score >= 80.

If you want an owned copy for a Copy type, use .copied():

let ana_score: Option<i32> = scores.get("ana").copied();

Now the type flow is:

  • get("ana") -> Option<&i32>
  • copied() -> Option<i32>

map() Changes the Inner Value

Use map() when you want to transform the value inside Some or Ok.

let maybe_name = Some("  andres  ");

let trimmed = maybe_name.map(|name| name.trim());

assert_eq!(trimmed, Some("andres"));

map() keeps the outer container the same:

  • Option<T> stays Option<U>
  • Result<T, E> stays Result<U, E>

Example with Result:

let parsed = "42".parse::<i32>().map(|n| n * 2);

assert_eq!(parsed, Ok(84));

and_then() Chains Fallible Steps

Use and_then() when your closure already returns Option or Result.

If you use map() in that case, you get nesting.

fn non_empty(text: &str) -> Option<&str> {
    let trimmed = text.trim();
    if trimmed.is_empty() {
        None
    } else {
        Some(trimmed)
    }
}

let nested = Some(" hello ").map(non_empty);
let flat = Some(" hello ").and_then(non_empty);

assert_eq!(nested, Some(Some("hello")));
assert_eq!(flat, Some("hello"));

So the short difference is:

  • map: T -> U
  • and_then: T -> Option<U> or T -> Result<U, E>

The same idea works with Result:

fn parse_port(text: &str) -> Result<u16, std::num::ParseIntError> {
    text.parse::<u16>()
}

fn checked_port(port: u16) -> Result<u16, String> {
    if port == 0 {
        Err("port must be greater than 0".to_string())
    } else {
        Ok(port)
    }
}

Because the error types differ, a String-based demo is easier to read here:

fn parse_nonzero_port(text: &str) -> Result<u16, String> {
    text.parse::<u16>()
        .map_err(|e| e.to_string())
        .and_then(checked_port)
}

Type flow:

  1. parse::<u16>() -> Result<u16, ParseIntError>
  2. map_err(...) -> Result<u16, String>
  3. and_then(checked_port) -> Result<u16, String>

? Unwraps Success and Returns Early on Failure

The ? operator is a short form for:

  • take the inner value from Ok(...) or Some(...)
  • if it is Err(...) or None, return early from the current boundary
fn read_port(text: &str) -> Result<u16, std::num::ParseIntError> {
    let port = text.parse::<u16>()?;
    Ok(port)
}

This is similar in spirit to:

fn read_port(text: &str) -> Result<u16, std::num::ParseIntError> {
    match text.parse::<u16>() {
        Ok(port) => Ok(port),
        Err(err) => Err(err),
    }
}

The important mental model is not just “unwrap or return.”

It is:

? returns early from the current Result or Option boundary.

A Plain Block Does Not Create a New ? Boundary

A plain code block can create scope, and it can produce a value:

let doubled = {
    let base = 21;
    base * 2
};

assert_eq!(doubled, 42);

But it does not create a new ? boundary.

fn port_plus_one(text: &str) -> Result<u16, std::num::ParseIntError> {
    let port = {
        text.parse::<u16>()?
    };

    Ok(port + 1)
}

Here, the ? does not return from the block. It returns from port_plus_one.

The block only groups expressions and limits scope.

A Closure Can Create a New ? Boundary

Closures are different because they have their own return type.

fn parse_inside_closure(text: &str) -> Result<u16, std::num::ParseIntError> {
    let port = (|| -> Result<u16, std::num::ParseIntError> {
        let parsed = text.parse::<u16>()?;
        Ok(parsed + 1)
    })()?;

    Ok(port)
}

Inside the closure, ? returns from the closure body, not directly from parse_inside_closure.

Then the outer ()? handles the closure’s Result.

That is the real boundary difference:

  • plain block: new scope, maybe a value, no new ? boundary
  • closure: new function-like body, yes new ? boundary if it returns Option or Result

Option Pipelines Often Explain Themselves by Type

Here is a more complete example with HashMap::get.

use std::collections::HashMap;

fn bonus_for(name: &str, scores: &HashMap<String, i32>) -> Option<i32> {
    scores
        .get(name)
        .copied()
        .filter(|score| *score >= 80)
        .map(|score| score + 5)
}

Type flow:

  1. get(name) -> Option<&i32>
  2. copied() -> Option<i32>
  3. filter(...) -> still Option<i32>
  4. map(...) -> still Option<i32>

Read it left to right:

get the score, copy it out if present, keep it only if it is at least 80, then add 5.

That is most of functional Rust in practice: follow the container shape while watching the inner type change.

Turning Option Into Result

Sometimes “missing” stops being acceptable and should become an error.

fn required_bonus_for(
    name: &str,
    scores: &HashMap<String, i32>,
) -> Result<i32, String> {
    scores
        .get(name)
        .copied()
        .ok_or_else(|| format!("missing score for {name}"))
        .map(|score| score + 5)
}

Type flow:

  1. get(name) -> Option<&i32>
  2. copied() -> Option<i32>
  3. ok_or_else(...) -> Result<i32, String>
  4. map(...) -> Result<i32, String>

TL;DR

  • Option means absence is expected
  • Result means failure should carry an error
  • map() transforms the inner value
  • and_then() chains another fallible step without nesting
  • ? returns early from the current Option or Result boundary
  • a plain block creates scope and may produce a value, but it does not create a new ? boundary
  • a closure can create a new ? boundary because it has its own return type
  • HashMap::get returns references, so watch the type flow carefully

If you want the iterator side of the same left-to-right mental model, see iterator-laziness-consumption-fold-collect.qmd.