Option values without writing a single match;map, and_then, and filter build clean option pipelines that short-circuit on absence;ok_or and ok_or_else promote a missing value into a Result error you can propagate;? operator works on Option for early returns, and how take/as_ref/zip round out the toolkit;Option eliminates the null-pointer mistakes that have plagued other languages for half a century.Result combinators (episode 46);Learn Rust Series):Last episode we spent a long, happy afternoon with Result and its combinators, and I closed by promising that Option had a whole ergonomic toolkit of its own that deserved its own post. Here we are ;-) The good news is that if episode 46 clicked, this one is largely recognition rather than fresh learning: Option and Result are cousins, and their combinators rhyme almost method for method. map is map, and_then is and_then, unwrap_or is unwrap_or. What is genuinely new is the why underneath it all -- because Option is Rust's answer to the single most expensive mistake in the history of programming languages, and understanding that mistake is what turns these little methods from "nice syntax" into "a category of crash that simply cannot happen in your code".
Having said that, before we look forward we owe episode 46 its homework. I left three exercises on the Result combinators, and skipping the solutions would be cheating you.
Episode 46 was all about map, map_err, and_then, and the bridges between Result and Option. Here is full, runnable code for each exercise -- the whole program, not a fragment, so you can paste and run it.
Exercise 1 asked you to parse a &str to f64, use map_err to turn a parse failure into a custom message, and use map to round the result to the nearest integer, all in one chain returning Result<i64, String>:
fn parse_round(s: &str) -> Result<i64, String> {
s.parse::<f64>()
.map_err(|_| format!("'{s}' is not a number"))
.map(|f| f.round() as i64)
}
fn main() {
println!("{:?}", parse_round("3.7")); // Ok(4)
println!("{:?}", parse_round("-2.4")); // Ok(-2)
println!("{:?}", parse_round("nope")); // Err("'nope' is not a number")
}
The key insight is the order: map_err first reshapes the failure path into our own vocabulary, and map then reshapes the success path (round, then cast). Because map never touches an Err, the "nope" case flows straight past it untouched. One chain, two independent transformations, no match.
Exercise 2 wanted two fallible steps chained with and_then: first parse a &str to a usize, then use that as a key into a small HashMap<usize, &str>, returning ok_or "no such key" when the lookup misses:
use std::collections::HashMap;
fn lookup(s: &str, table: &HashMap<usize, &str>) -> Result<String, String> {
s.parse::<usize>()
.map_err(|_| format!("'{s}' is not an index"))
.and_then(|i| {
table.get(&i)
.map(|word| word.to_string())
.ok_or(format!("no such key {i}"))
})
}
fn main() {
let table: HashMap<usize, &str> = [(0, "zero"), (1, "one"), (2, "two")].into_iter().collect();
println!("{:?}", lookup("2", &table)); // Ok("two")
println!("{:?}", lookup("9", &table)); // Err("no such key 9")
println!("{:?}", lookup("x", &table)); // Err("'x' is not an index")
}
Notice the two different failure modes producing two different errors. A bad string fails at the parse/map_err stage; a valid index that is simply absent fails at the ok_or inside the and_then. And HashMap::get returns an Option, which is exactly the Option-to-Result bridge that ok_or exists to cross -- the same bridge that is one of the stars of this episode.
Exercise 3 was the collect trick: given a Vec<&str> of numbers where one entry is garbage, use .collect::<Result<Vec<i32>, _>>() to show it short-circuits to an Err, then swap the garbage for a valid number and show it becomes Ok:
fn main() {
let garbage = ["1", "2", "x", "4"];
let r: Result<Vec<i32>, _> = garbage.iter().map(|s| s.parse::<i32>()).collect();
println!("is err? {}", r.is_err()); // true -- it stopped at "x"
let clean = ["1", "2", "3", "4"];
let r2: Result<Vec<i32>, _> = clean.iter().map(|s| s.parse::<i32>()).collect();
println!("{r2:?}"); // Ok([1, 2, 3, 4])
}
collect sees an iterator of Results and, thanks to a clever FromIterator impl, hands back a single Result<Vec<i32>, _> -- Ok with every value if all succeed, or the first Err the instant one fails. A whole-batch validator in a single word. Right, homework cleared. Now, Option.
Let me set the stage properly, because it matters. In 1965 Tony Hoare introduced the null reference into ALGOL W, and decades later he called it his "billion-dollar mistake" -- an offhand invention that has since caused an uncountable pile of crashes, security holes, and 2 a.m. pages. The problem is not the concept of "a value might be absent"; that is legitimate and everywhere. The problem is that in most languages, every reference is secretly nullable. A String in Java, an object pointer in C, a reference in older C#: any of them can silently be null, and the type system says nothing. You find out when you dereference it and the whole thing falls over.
Rust simply does not have null. In stead of every reference secretly being able to be absent, absence is made an explicit, visible thing in the type: a value that might not be there has type Option<T>, which is nothing more than a plain enum with two variants -- the standard library defines it essentially as enum Option<T> { None, Some(T) }, and it lives in the prelude so you never even import it:
// Conceptually, std defines: enum Option { None, Some(T) }
fn main() {
let present: Option<i32> = Some(5);
let absent: Option<i32> = None;
println!("{present:?} {absent:?}"); // Some(5) None
}
That is the whole trick, and it is enormous. Because the possibility of absence is now in the type, the compiler forces you to handle the None case before you can ever touch the inner value. There is no way to "accidentally" use an absent value, because Option<i32> is not an i32 -- you cannot add it, index with it, or print it as a number until you have dealt with the None. An entire class of crash, the null-pointer dereference, is designed out of existence at compile time. The combinators are simply the ergonomic way to do that handling, so that "deal with the None" does not mean a match on every line.
The core three transform an Option without you ever unwrapping it by hand. map applies a function to the inner value if it is Some. and_then chains a step that itself returns an Option and flattens the result. filter keeps a Some only when a predicate passes, otherwise collapses it to None:
fn main() {
let some: Option<i32> = Some(5);
println!("{:?}", some.map(|n| n * 2)); // Some(10)
let nested: Option<Option<i32>> = Some(Some(3));
println!("{:?}", nested.and_then(|inner| inner)); // Some(3), flattened
println!("{:?}", Some(4).filter(|n| n % 2 == 0)); // Some(4)
println!("{:?}", Some(3).filter(|n| n % 2 == 0)); // None -- predicate failed
}
A None flows through all three of them completely untouched, and that is the property that makes chains work. Because absence short-circuits, you can stack a dozen of these methods and the moment any step produces None, every step after it is skipped and the whole expression is None. The same distinction from last episode applies here too, and it is worth burning in: use map when your closure returns a plain value T, and use and_then when your closure returns an Option<T>. Reach for map where you should have used and_then and you get an ugly Option<Option<T>>; that nested box is the compiler telling you to switch tools.
Here is and_then earning its keep in a small chain where each step can genuinely fail:
fn half_if_even(n: i32) -> Option<i32> {
if n % 2 == 0 { Some(n / 2) } else { None }
}
fn main() {
println!("{:?}", Some(8).and_then(half_if_even).and_then(half_if_even)); // Some(2)
println!("{:?}", Some(6).and_then(half_if_even).and_then(half_if_even)); // None
}
Trace the second line: 8 halves to 4, which halves to 2, so we get Some(2). But 6 halves to 3, and 3 is odd, so the second half_if_even returns None -- and because absence short-circuits, we get None with no further work. This is the exact same shape as the Result pipeline from episode 46, just without an error payload riding along.
The same fallback family you met on Result applies verbatim to Option: unwrap_or supplies a fixed default, unwrap_or_else computes one lazily, and unwrap_or_default uses the type's Default. And the star of the episode title, ok_or, is the bridge that promotes a None into a real Err you can propagate:
fn main() {
let present: Option<i32> = Some(7);
let absent: Option<i32> = None;
println!("{}", present.unwrap_or(0)); // 7
println!("{}", absent.unwrap_or(0)); // 0
println!("{}", absent.unwrap_or_else(|| 1 + 1)); // 2, computed only because it was None
let r1: Result<i32, String> = present.ok_or(String::from("missing")); // Ok(7)
let r2: Result<i32, String> = absent.ok_or(String::from("missing")); // Err("missing")
println!("{r1:?} {r2:?}");
}
ok_or is the exact counterpart to Result::ok from last episode: ok drops an error to demote a Result into an Option, and ok_or attaches an error to promote an Option into a Result. You will cross this bridge constantly, because half of the standard library speaks Option -- HashMap::get, Vec::first, slice::get, Iterator::next -- and the moment you want one of those "not found" answers to become a propagatable error, ok_or (fixed error) or ok_or_else (error computed only when needed) is how you do it. As always, prefer the _else variant when building the error costs something, so you do not pay for a message you never use.
Just as ? propagates errors early in a function returning Result, it propagates absence early in a function returning Option. If the value is Some, ? unwraps it and carries on; if it is None, the function returns None right there:
fn first_char_upper(s: &str) -> Option<char> {
let c = s.chars().next()?; // empty string -> return None here
Some(c.to_ascii_uppercase())
}
fn main() {
println!("{:?}", first_char_upper("hello")); // Some('H')
println!("{:?}", first_char_upper("")); // None
}
Without ? you would write a match or an if let just to pull the char out; with it, the happy path stays flat and the absent path exits cleanly. And because ? composes, you can thread several fallible-because-absent steps through one function and let the first missing piece bail out for you:
fn ratio(s: &str) -> Option<f64> {
let mut parts = s.split('/');
let num: f64 = parts.next()?.trim().parse().ok()?;
let den: f64 = parts.next()?.trim().parse().ok()?;
if den == 0.0 { return None; }
Some(num / den)
}
fn main() {
println!("{:?}", ratio("10 / 4")); // Some(2.5)
println!("{:?}", ratio("10 / 0")); // None -- guarded against divide-by-zero
println!("{:?}", ratio("oops")); // None -- no '/', so the second next() is None
}
Look at how much is going on here with almost no ceremony. Each parts.next()? bails if a piece of the string is missing, and each .parse().ok()? converts a parse Result into an Option with ok and then propagates absence with ?. Four ways to fail -- missing numerator, missing denominator, unparseable number, zero denominator -- and every one of them returns a tidy None without a single match.
Option also carries a set of methods that mutate in place, and they tie straight back to the std::mem toolkit from episode 43. take swaps the value out and leaves None behind, handing you ownership of what was there; get_or_insert fills a None with a value and returns a mutable reference to whatever is now inside; and as_ref lets you look without moving:
fn main() {
let text: Option<String> = Some(String::from("hello"));
let len: Option<usize> = text.as_ref().map(|s| s.len()); // borrow, do not move
println!("{len:?} still have {text:?}"); // Some(5) still have Some("hello")
let mut slot: Option<String> = Some(String::from("data"));
let taken = slot.take(); // slot is now None; taken owns the String
println!("taken {taken:?}, slot {slot:?}"); // taken Some("data"), slot None
let mut cached: Option<i32> = None;
let value = cached.get_or_insert(42); // was None, now Some(42)
*value += 1;
println!("{cached:?}"); // Some(43)
}
That as_ref is the unsung hero of the bunch. Without it, text.map(|s| s.len()) would move the String out of text, leaving it unusable afterward -- as_ref turns an &Option<String> into an Option<&String> so you can inspect the inner value and keep the original intact. It is the small move that keeps ownership errors off your back when you only wanted to peek. And take is precisely the pattern we leaned on when building linked structures back in episode 45: pull a value out of a field, leaving a valid None in its place, with never an invalid intermediate state.
Put the pieces together and optional logic reads straight down the page. Looking up a score, checking it passes, and turning it into a grade with a default becomes one honest expression:
use std::collections::HashMap;
fn main() {
let mut scores = HashMap::new();
scores.insert("alice", 90);
scores.insert("bob", 45);
let grade = scores
.get("alice") // Option
.filter(|&&s| s >= 50) // keep only if passing
.map(|_| "pass") // a surviving score becomes "pass"
.unwrap_or("fail"); // default for missing OR failing
println!("alice: {grade}"); // alice: pass
let carol = scores.get("carol").copied().unwrap_or(0); // absent key -> default
println!("carol: {carol}"); // carol: 0
}
There is no null, no -1 sentinel, no magic "not found" integer, and no way to accidentally use an absent score -- because the type system threaded the possibility of absence through every step. Note how filter and a missing key collapse to the same None, so unwrap_or("fail") covers both "bob-style failing" and "carol-style missing" in one stroke. That is null-free programming in one sentence: absence is visible in the types and handled by construction, so the crash a null dereference would cause simply cannot occur.
A couple more members of the family are worth a mention because they show up often. or and or_else supply a whole alternative Option when the first is None, and zip combines two Options into an Option of a pair, which is None if either half is missing:
fn main() {
let a: Option<i32> = None;
println!("{:?}", a.or(Some(1))); // Some(1) -- fall back to another Option
println!("{:?}", a.or_else(|| Some(2))); // Some(2) -- computed lazily
let x = Some(3);
let y = Some("three");
println!("{:?}", x.zip(y)); // Some((3, "three"))
let z: Option<i32> = None;
println!("{:?}", x.zip(z)); // None -- one half missing sinks the whole thing
}
And, mirroring the Result trick from last episode, an iterator of Options collects into a single Option<Vec<_>> that becomes None the moment any element is absent:
fn main() {
let all: Option<Vec<i32>> = ["1", "2", "3"].iter().map(|s| s.parse::<i32>().ok()).collect();
println!("{all:?}"); // Some([1, 2, 3])
let some_missing: Option<Vec<i32>> = ["1", "x"].iter().map(|s| s.parse::<i32>().ok()).collect();
println!("{some_missing:?}"); // None -- "x" failed to parse
}
Since quite some of you arrived here from the Learn Python Series, a look sideways sharpens the point. In Python there is no Option type, and absence is spelled None -- but crucially, any variable can be None, and nothing in a function's signature warns you. You call d.get("key"), it returns None for a missing key, and if you forget to check, None.upper() throws AttributeError at runtime, in production, at 3 a.m. Python's None is exactly Hoare's null wearing a friendlier name. Rust's Option<T> looks superficially similar but is the opposite in spirit: the None case is in the type and the compiler will not let you skip it. The combinators are how you keep "always check" from becoming "always write a tedious if x is not None".
In Go, the corresponding pattern is the comma-ok idiom (v, ok := m[key]) and nil pointers, and nil is famously still dereferenceable into a panic. There is no map/and_then to compress a chain, so where a Rust programmer writes three chained combinators, a Go programmer writes three if ok blocks. And in the ML-family languages -- Haskell's Maybe, OCaml's option, Swift's optionals, C++'s std::optional -- you will find the same shape, because that is where Rust borrowed it. Haskell's Maybe has fmap (our map) and monadic bind (our and_then); Swift's ?. optional chaining and ?? nil-coalescing are and_then and unwrap_or in disguise. Rust deliberately skipped the scary vocabulary -- you will never be quizzed on the word "monad" to use and_then -- but the battle-tested idea is identical: a container with two cases and a small set of lawful transformations over it. You get the ergonomics without needing the category theory ;-)
Option<T> is Rust's null, done right: absence is an explicit enum variant baked into the type, so the compiler forces you to handle None before touching the value -- the null-pointer dereference is designed out of existence.map, and_then, and filter transform an Option without unwrapping: map for a closure returning a plain value, and_then for a closure returning an Option (and it flattens), filter to collapse a Some that fails a predicate. None short-circuits all of them.Option world: unwrap_or (fixed), unwrap_or_else (lazy), unwrap_or_default (uses Default) -- all far safer than the panicking unwrap.ok_or and ok_or_else bridge to Result, promoting a None into a propagatable Err, the exact counterpart to Result::ok -- the join you use every time a HashMap::get needs to become a real error.? propagates absence in a function returning Option, keeping the happy path flat; and the in-place helpers take, get_or_insert, and as_ref tie back to the std::mem and ownership work from earlier episodes.Result combinators -- almost any optional flow becomes a flat, readable pipeline, with the type system guaranteeing you handled the absent case.We now have both halves of Rust's fallibility story: Result for "it failed and here is why", Option for "there is nothing here". So far our errors have been plain Strings, which is fine for a tutorial but crude for real code -- you cannot match on a String, you cannot attach structured data to it, and callers cannot tell your errors apart programmatically. The natural next question is how to build a real, structured error type of your own that plays nicely with ? and the wider ecosystem. That is where we head next.
Three exercises, gentle to chewier. Type them yourself before the next episode -- combinators only truly click once your own fingers have chained a few.
Option<&str> using split_whitespace().nth(1), then map it to an uppercase String. Test it on "hello world", on a single-word string, and on "".HashMap::get, filter, and ok_or to look up a config value by key and validate it (say, keep it only if it is non-empty), producing a Result<&str, String> where the error explains whether the key was missing or the value was invalid.? on Option to write a function that safely divides the first two whitespace-separated numbers parsed from a string, returning Option<f64> and yielding None if either number is missing or unparseable.De groeten, en tot de volgende keer! ;-)