Rust Option vs Result: ?, blocks, closures, and type flow
Option<T> and Result<T, E> are two of the most important shapes in Rust.
They are different, but they feel similar on purpose.
Option<T>means: a value may or may not be presentResult<T, E>means: an operation may succeed or fail
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
Optionwhen “not found” or “not present” is an expected outcome - use
Resultwhen 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:
scoresisHashMap<&str, i32>scores.get("ana")isOption<&i32>is_some_and(...)checks theSomecase and returnsbool
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>staysOption<U>Result<T, E>staysResult<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 -> Uand_then:T -> Option<U>orT -> 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:
parse::<u16>()->Result<u16, ParseIntError>map_err(...)->Result<u16, String>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(...)orSome(...) - if it is
Err(...)orNone, 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 currentResultorOptionboundary.
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 returnsOptionorResult
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:
get(name)->Option<&i32>copied()->Option<i32>filter(...)-> stillOption<i32>map(...)-> stillOption<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:
get(name)->Option<&i32>copied()->Option<i32>ok_or_else(...)->Result<i32, String>map(...)->Result<i32, String>
TL;DR
Optionmeans absence is expectedResultmeans failure should carry an errormap()transforms the inner valueand_then()chains another fallible step without nesting?returns early from the currentOptionorResultboundary- 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::getreturns 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.