Learn Rust Series (#46) - Result Combinators: map, map_err, and_then, ok_or
Learn Rust Series (#46) - Result Combinators: map, map_err, and_then, ok_or
What will I learn
- You will learn how to transform a
Resultwithout writing amatchat every step; - how
mapandmap_errchange theOkandErrsides independently; - how
and_thenchains fallible operations, and howor_elserecovers from errors; - the
unwrap_or,unwrap_or_else, andunwrap_or_defaultfamily for supplying fallbacks; - how
ok,ok_or, andok_or_elsebridge betweenResultandOption, and how to build clean pipelines.
Requirements
- A working modern computer running macOS, Windows or Ubuntu;
- An installed Rust toolchain (via rustup, from rustup.rs);
- The previous forty-five episodes, especially error handling (episode 6) and closures (episode 11);
- The ambition to learn systems programming from the ground up.
Difficulty
- Intermediate
Curriculum (of the Learn Rust Series):
- Learn Rust Series (#1) - Introduction to Rust
- Learn Rust Series (#2) - Variables, Types, Functions
- Learn Rust Series (#3) - Ownership & Borrowing
- Learn Rust Series (#4) - Control Flow & Pattern Matching
- Learn Rust Series (#5) - Structs & Enums
- Learn Rust Series (#6) - Error Handling
- Learn Rust Series (#7) - Collections
- Learn Rust Series (#8) - Traits & Generics
- Learn Rust Series (#9) - Modules & Crates
- Learn Rust Series (#10) - Lifetimes
- Learn Rust Series (#11) - Closures & the Iterator Trait
- Learn Rust Series (#12) - Smart Pointers: Box, Rc & RefCell
- Learn Rust Series (#13) - Concurrency: Threads, Channels, Arc & Mutex
- Learn Rust Series (#14) - Mini Project: A Command-Line To-Do App
- Learn Rust Series (#15) - Trait Objects & Dynamic Dispatch
- Learn Rust Series (#16) - Static vs Dynamic Dispatch
- Learn Rust Series (#17) - Associated Types vs Generic Parameters
- Learn Rust Series (#18) - Operator Overloading with std::ops
- Learn Rust Series (#19) - Deref, DerefMut & Deref Coercion
- Learn Rust Series (#20) - Drop & Deterministic Destruction (RAII)
- Learn Rust Series (#21) - From, Into, TryFrom & Idiomatic Conversions
- Learn Rust Series (#22) - Deriving Common Traits
- Learn Rust Series (#23) - The Orphan Rule & Trait Coherence
- Learn Rust Series (#24) - Blanket Implementations & the Newtype Pattern
- Learn Rust Series (#25) - Marker Traits: Sized, Send, Sync & Copy
- Learn Rust Series (#26) - Const Generics: Types That Depend on Values
- Learn Rust Series (#27) - Generic Associated Types & Lending Iterators
- Learn Rust Series (#28) - Sealed Traits & Designing Stable APIs
- Learn Rust Series (#29) - Typestate Programming: State Machines in the Type System
- Learn Rust Series (#30) - Mini Project: A Generic Units-of-Measure Library
- Learn Rust Series (#31) - Move Semantics Deep Dive
- Learn Rust Series (#32) - Interior Mutability: Cell & RefCell
- Learn Rust Series (#33) - Rc Internals: Reference Counting & Shared Ownership
- Learn Rust Series (#34) - Arc: Thread-Safe Reference Counting & Its Cost
- Learn Rust Series (#35) - Weak References & Breaking Reference Cycles
- Learn Rust Series (#36) - Cow: Clone-on-Write for Borrow-or-Own APIs
- Learn Rust Series (#37) - Pin & Self-Referential Structs
- Learn Rust Series (#38) - PhantomData, Zero-Sized Types & Marker Lifetimes
- Learn Rust Series (#39) - Variance: Covariance, Contravariance & Why It Matters
- Learn Rust Series (#40) - Arena & Bump Allocation Patterns
- Learn Rust Series (#41) - Building Your Own Smart Pointer
- Learn Rust Series (#42) - Drop Order, the Drop Check & Leak Safety
- Learn Rust Series (#43) - std::mem: swap, replace, take & forget
- Learn Rust Series (#44) - Higher-Ranked Trait Bounds & Lifetime Elision
- Learn Rust Series (#45) - Mini Project: A Doubly-Linked List, Safe then Unsafe
- Learn Rust Series (#46) - Result Combinators: map, map_err, and_then, ok_or (this post)
Learn Rust Series (#46) - Result Combinators: map, map_err, and_then, ok_or
Welcome to Phase 4, which is all about making code robust and shippable, and it begins where every real program spends a serious slice of its life: handling errors. We are done, for now, with the deep memory-model spelunking of Phase 3 - the Rcs and the Weaks and the raw pointers - and we are going to do something rather more relaxing, as I promised at the end of last episode. You already met Result and the ? operator way back in episode 6. What you did not get then were its combinators: the methods that transform, chain, and unwrap a Result without a match block at every corner. Written well, error-handling code stops looking like a staircase of nested matches and starts reading like a clean pipeline of operations, top to bottom ;-)
Having said that, let me be clear about what this episode is and is not. It is not "here is a wall of forty methods, memorise them". It is a small, carefully chosen toolkit - maybe eight methods - that between them cover almost every fallible flow you will ever write. Learn these eight and the match blocks largely melt away. But before we get to any of that, we owe episode 45 its homework, because last time I left three exercises on the doubly-linked list, and skipping the solutions would be cheating you.
Solutions to Episode 45 Exercises
Episode 45 was the doubly-linked list, built safely with Rc/RefCell/Weak and then unsafely with raw pointers. There were three exercises, and here is full, runnable code for each - the whole program, not a fragment, so you can paste and run it.
Exercise 1 asked for a push_back on the safe list, using the tail weak pointer. The trick is to upgrade the stored Weak into a real Rc so you can attach the new node behind it:
use std::rc::{Rc, Weak};
use std::cell::RefCell;
type Link = Option<Rc<RefCell<Node>>>;
struct Node { value: i32, next: Link, prev: Option<Weak<RefCell<Node>>> }
struct DoublyLinkedList { head: Link, tail: Option<Weak<RefCell<Node>>> }
impl DoublyLinkedList {
fn new() -> DoublyLinkedList { DoublyLinkedList { head: None, tail: None } }
fn push_back(&mut self, value: i32) {
let node = Rc::new(RefCell::new(Node { value, next: None, prev: None }));
match self.tail.take().and_then(|w| w.upgrade()) {
Some(old_tail) => {
node.borrow_mut().prev = Some(Rc::downgrade(&old_tail));
old_tail.borrow_mut().next = Some(Rc::clone(&node));
}
None => self.head = Some(Rc::clone(&node)),
}
self.tail = Some(Rc::downgrade(&node));
}
fn to_vec(&self) -> Vec<i32> {
let mut out = Vec::new();
let mut cursor = self.head.clone();
while let Some(node) = cursor {
out.push(node.borrow().value);
cursor = node.borrow().next.clone();
}
out
}
}
fn main() {
let mut list = DoublyLinkedList::new();
list.push_back(1);
list.push_back(2);
println!("{:?}", list.to_vec()); // [1, 2]
}
The key insight is self.tail.take().and_then(|w| w.upgrade()): take pulls the old tail's Weak out, and upgrade turns it into an Rc only if that node is still alive. If the list was empty there is no tail to upgrade, so the new node becomes the head instead. Either way, the new node ends up as the tail, held weakly, so no cycle forms.
Exercise 2 wanted a pop_front on the unsafe RawList, unlinking the head, reclaiming it, and fixing up the new head's prev:
use std::ptr::NonNull;
struct Node { value: i32, next: Option<NonNull<Node>>, prev: Option<NonNull<Node>> }
struct RawList { head: Option<NonNull<Node>>, tail: Option<NonNull<Node>>, len: usize }
impl RawList {
fn new() -> RawList { RawList { head: None, tail: None, len: 0 } }
fn push_front(&mut self, value: i32) {
let node = NonNull::new(Box::into_raw(Box::new(Node { value, next: self.head, prev: None }))).unwrap();
match self.head {
Some(mut old) => unsafe { old.as_mut().prev = Some(node) },
None => self.tail = Some(node),
}
self.head = Some(node);
self.len += 1;
}
fn pop_front(&mut self) -> Option<i32> {
self.head.map(|node| {
// SAFETY: node came from Box::into_raw and is still live and owned by us.
let boxed = unsafe { Box::from_raw(node.as_ptr()) };
self.head = boxed.next;
match self.head {
Some(mut new_head) => unsafe { new_head.as_mut().prev = None },
None => self.tail = None,
}
self.len -= 1;
boxed.value
})
}
}
impl Drop for RawList {
fn drop(&mut self) {
let mut cursor = self.head;
while let Some(node) = cursor {
// SAFETY: reclaim each leaked Box exactly once, in order.
unsafe { let boxed = Box::from_raw(node.as_ptr()); cursor = boxed.next; }
}
}
}
fn main() {
let mut list = RawList::new();
list.push_front(2);
list.push_front(1);
println!("{:?}", list.pop_front()); // Some(1)
println!("{:?}", list.pop_front()); // Some(2)
println!("{:?} len {}", list.pop_front(), list.len); // None len 0
}
Box::from_raw is the star: it reclaims ownership of the leaked head allocation, so when boxed drops at the end of the closure the memory is freed exactly once. When the list becomes empty we clear the tail too, otherwise it would dangle. Notice the code returns cleanly even after everything is popped - the surviving Drop still walks a now-empty list and frees nothing, which is correct.
Exercise 3 was a to_vec_reversed on the safe list, walking backward from tail along the prev links:
use std::rc::{Rc, Weak};
use std::cell::RefCell;
type Link = Option<Rc<RefCell<Node>>>;
struct Node { value: i32, next: Link, prev: Option<Weak<RefCell<Node>>> }
struct DoublyLinkedList { head: Link, tail: Option<Weak<RefCell<Node>>> }
impl DoublyLinkedList {
fn new() -> DoublyLinkedList { DoublyLinkedList { head: None, tail: None } }
fn push_front(&mut self, value: i32) {
let node = Rc::new(RefCell::new(Node { value, next: self.head.take(), prev: None }));
match &node.borrow().next {
Some(next) => next.borrow_mut().prev = Some(Rc::downgrade(&node)),
None => self.tail = Some(Rc::downgrade(&node)),
}
self.head = Some(node);
}
fn to_vec(&self) -> Vec<i32> {
let mut out = Vec::new();
let mut cursor = self.head.clone();
while let Some(node) = cursor {
out.push(node.borrow().value);
cursor = node.borrow().next.clone();
}
out
}
fn to_vec_reversed(&self) -> Vec<i32> {
let mut out = Vec::new();
let mut cursor = self.tail.clone().and_then(|w| w.upgrade());
while let Some(node) = cursor {
out.push(node.borrow().value);
cursor = node.borrow().prev.clone().and_then(|w| w.upgrade());
}
out
}
}
fn main() {
let mut list = DoublyLinkedList::new();
list.push_front(3);
list.push_front(2);
list.push_front(1);
println!("{:?}", list.to_vec()); // [1, 2, 3]
println!("{:?}", list.to_vec_reversed()); // [3, 2, 1]
}
Each backward step is prev.clone().and_then(|w| w.upgrade()) - because prev is a Weak, we must upgrade it to touch the node, and and_then gracefully stops the walk if any link has died. And there it is: and_then and upgrade already showing up in last week's homework, which is the perfect segue, because and_then is one of the stars of this episode. Right, homework cleared. Now, Result combinators.
map and map_err: transform each side
A Result<T, E> has two sides, and the first thing you want to do is transform one of them without disturbing the other. map applies a function to the Ok value and leaves an Err completely untouched; map_err does the mirror image, transforming the error and leaving Ok alone:
fn main() {
let ok: Result<i32, String> = Ok(5);
let doubled = ok.map(|n| n * 2); // Ok(10)
println!("{doubled:?}");
let err: Result<i32, String> = Err(String::from("bad input"));
let wrapped = err.map_err(|e| format!("error: {e}")); // Err("error: bad input")
println!("{wrapped:?}");
}
Think of it this way: map is "if this succeeded, keep going with the success reshaped", and map_err is "if this failed, translate the failure into my vocabulary". That second one is more important than it looks. A huge amount of real error handling is translation - a low-level std::io::Error or a ParseIntError bubbling up from some library, which you want to convert into your program's error type before it goes any further. That is exactly the job map_err does, and it is the very same conversion the ? operator performs automatically via the From trait we studied in episode 21. When ? does not have a From impl to lean on, a manual map_err is how you bridge the gap.
The mental model that helps here: a Result is a box with two compartments, and map/map_err reach into exactly one compartment, apply your closure, and put the box back together unchanged in every other respect. Neither method can turn an Ok into an Err or vice versa - they preserve the variant. For that you need the next tool.
and_then: chain operations that can themselves fail
map is for a function that always succeeds - double a number, format a string. But very often the next step in a computation can itself fail, and returns its own Result. If you reach for map there, you get a Result<Result<T, E>, E> - a box inside a box, which is a nuisance. and_then is the answer: it chains a fallible step and flattens the result, so a chain of fallible steps stays flat:
fn parse_positive(s: &str) -> Result<i32, String> {
s.parse::<i32>()
.map_err(|_| format!("'{s}' is not a number"))
.and_then(|n| {
if n > 0 { Ok(n) } else { Err(format!("{n} is not positive")) }
})
}
fn main() {
println!("{:?}", parse_positive("42")); // Ok(42)
println!("{:?}", parse_positive("-1")); // Err("-1 is not positive")
println!("{:?}", parse_positive("abc")); // Err("'abc' is not a number")
}
Read parse_positive as a little assembly line. First we try to parse the string; if that fails we translate the parse error into a friendly message with map_err. Then and_then runs a second fallible check - is the number positive? - which returns its own Ok/Err. The lovely part is the short-circuiting: if the parse fails, the and_then closure never runs at all, and the first error wins. Each step only executes if every step before it succeeded. If you know Haskell or Scala, and_then is the "flat map" or "bind" of Result; if you do not, just remember it as "chain another thing that might fail, and keep it flat".
The distinction between map and and_then is the single most important idea in this episode, so let me put it bluntly. Use map when your closure returns a plain value T. Use and_then when your closure returns a Result<T, E>. Get those two straight and everything else falls into place.
Fallbacks: unwrap_or and its family
Sometimes you do not want to propagate an error at all - you want to leave the Result world right here, with a sensible default, and carry on with a plain value. The unwrap_or family does exactly that, and each member trades off differently:
fn main() {
let ok: Result<i32, String> = Ok(10);
let err: Result<i32, String> = Err(String::from("oops"));
println!("{}", ok.unwrap_or(0)); // 10
println!("{}", err.clone().unwrap_or(0)); // 0
println!("{}", err.clone().unwrap_or_else(|e| e.len() as i32)); // 4
let missing: Result<i32, String> = Err(String::from("x"));
println!("{}", missing.unwrap_or_default()); // 0
}
Three flavours, three use-cases. unwrap_or(value) hands back a fixed fallback you supply up front - simplest, but the fallback is always evaluated even when the Result is Ok, so keep it cheap. unwrap_or_else(closure) computes the fallback lazily, only when there actually is an error, and it even hands you the error so you can decide the fallback based on what went wrong - use this when building the default costs something (an allocation, a lookup). And unwrap_or_default() uses the type's Default impl, which for i32 is 0, for String is "", for Vec is empty - handy when "the zero value is fine" is genuinely true.
All three are dramatically safer than the blunt unwrap(), which panics the whole thread on an Err, and its cousin expect(), which panics with a message of your choosing. Save unwrap and expect for the genuinely-cannot-fail cases (a hard-coded regex that you know compiles, a value you just inserted) - and even then, expect with a clear message is kinder to whoever debugs the panic at 2 a.m. In everyday code, prefer the unwrap_or family or, better yet, propagate with ? and let the caller decide.
Converting and recovering: ok, ok_or, or_else
Result and Option are close cousins - both encode "there might not be a value here" - and Rust gives you clean bridges in both directions. ok throws away the error and turns a Result<T, E> into an Option<T>. or_else is the mirror of and_then: where and_then chains on success, or_else runs a recovery closure on failure:
fn main() {
let good: Result<i32, String> = Ok(7);
let as_option: Option<i32> = good.ok(); // Some(7); an Err would become None
println!("{as_option:?}");
let bad: Result<i32, String> = Err(String::from("primary failed"));
let recovered = bad.or_else(|_| Ok::<i32, String>(-1)); // fall back to Ok(-1)
println!("{recovered:?}");
}
and_then chains on Ok, or_else chains on Err, and together they let you express "try this; if it fails, try that instead" as a flat, readable chain rather than a nest of matches. The or_else recovery closure returns a Result of its own, so your fallback is also allowed to fail - which is exactly what you want when the recovery is itself a fallible operation (read from cache, else read from disk, else give up).
Now the other bridge, the one the episode title promised: ok_or and ok_or_else go the opposite way, turning an Option into a Result by supplying the error to use when the option is None:
fn main() {
let some: Option<i32> = Some(3);
let none: Option<i32> = None;
let r1: Result<i32, &str> = some.ok_or("was empty"); // Ok(3)
let r2: Result<i32, &str> = none.ok_or("was empty"); // Err("was empty")
println!("{r1:?} {r2:?}");
// ok_or_else computes the error lazily, only when the Option is None
let missing: Option<i32> = None;
let r3: Result<i32, String> = missing.ok_or_else(|| format!("missing at index {}", 7));
println!("{r3:?}"); // Err("missing at index 7")
}
This is the join between the two worlds that you will use constantly. A HashMap::get returns an Option; a Vec index-with-get returns an Option; the first element of an iterator is an Option. The moment you want that "not found" to become a real, propagatable error - so you can ? it up the call stack - you reach for ok_or (fixed error) or ok_or_else (error computed only when needed, same lazy logic as unwrap_or_else). Keep the naming straight and the whole family clicks: ok drops the error, ok_or supplies one.
A full pipeline
Put the pieces together and a multi-step fallible computation reads straight down the page with not a single explicit match in sight:
fn main() {
let result: Result<i32, String> = "20"
.parse::<i32>()
.map_err(|_| "not a number".to_string())
.map(|n| n * 2)
.and_then(|n| if n < 100 { Ok(n) } else { Err("too big".to_string()) });
println!("{result:?}"); // Ok(40)
}
Trace the flow once: parse the string, translate any parse error into our own message, double the parsed number (a step that cannot fail, so map), then validate the doubled value (a step that can fail, so and_then). Change the input to "9999" and the and_then returns Err("too big"); change it to "nope" and the very first map_err fires and everything after short-circuits. That is the whole philosophy of combinators - each method is a small honest transformation, and the type system guarantees the error handling threads through correctly no matter which step trips.
One more trick that feels like magic the first time you see it: an iterator of Results can be collected into a single Result<Vec<_>, _>, short-circuiting on the first error. This is one of the most useful patterns in all of Rust, and it leans on the same short-circuit logic:
fn main() {
let ok: Result<Vec<i32>, _> = ["1", "2", "3"].iter().map(|s| s.parse::<i32>()).collect();
println!("{ok:?}"); // Ok([1, 2, 3])
let bad: Result<Vec<i32>, _> = ["1", "x", "3"].iter().map(|s| s.parse::<i32>()).collect();
println!("is err? {}", bad.is_err()); // true, it stopped at "x"
}
The magic is that collect is generic over what it builds, and there is an impl that says "if you hand me an iterator of Results, I will give you a Result of a collection - Ok with all the values if every item succeeded, or the first Err the moment one fails". No manual loop, no match, no early return - one word, collect, and the fallibility is handled. You will use this to validate a whole batch of inputs in a single expression more times than you can count.
How this compares to other languages
Since quite some of you arrived here from the Learn Python Series, a look sideways makes the design choice sharper. In Python, there is no Result type in the language - fallibility travels by exceptions. You write int("20") and either you get a number or a ValueError comes flying up the stack, to be caught somewhere by a try/except or else to crash the program. It is convenient and it reads cleanly for the happy path, but the failure path is invisible in the type: nothing in a function's signature tells you it might raise, so you find out the hard way, in production, at 3 a.m. Rust's Result makes the failure a visible part of the return type, and the combinators are how you keep that visibility from turning into match noise. Python's nearest spiritual cousin to and_then is chaining calls and letting the exception short-circuit them; Rust makes the short-circuit explicit and checked.
In Go, error handling is famously the if err != nil { return err } dance after every fallible call. It is honest - the error is a real returned value, not a hidden exception, which is philosophically much closer to Rust - but it is verbose, and there is no built-in map/and_then to compress a chain of steps into a pipeline. Where a Rust programmer writes four chained combinators, a Go programmer writes four if err != nil blocks. Rust keeps Go's honesty (errors are values you cannot silently ignore, because a Result must be used) while handing you the combinators to make the honesty ergonomic.
And in the ML-family languages - Haskell, OCaml, Scala - this whole toolkit will look deeply familiar, because that is where Rust borrowed it. Haskell's Either has fmap (our map), and its monadic >>= bind (our and_then); Scala's Either and Try carry map, flatMap, getOrElse under names you can now guess. Rust deliberately did not import the scary vocabulary - you will not be quizzed on the word "monad" to use and_then - but the shape is the same, battle-tested idea: a container with two cases, and a small set of lawful transformations over it. The nice thing is you get the ergonomics without needing the category theory ;-)
What did we actually learn?
mapandmap_erreach transform one side of aResultand leave the other untouched;map_errin particular is how you translate a foreign error into your own type, the same job?does automatically viaFrom.and_thenchains a fallible step and flattens the result, short-circuiting on the first error - use it when your closure returns aResult. Usemapwhen your closure returns a plain value. That distinction is the heart of the episode.- The
unwrap_orfamily leaves theResultworld with a fallback:unwrap_or(fixed, always evaluated),unwrap_or_else(lazy, sees the error),unwrap_or_default(usesDefault). All three are far safer than the panickingunwrap/expect. ok,ok_or, andok_or_elsebridgeResultandOption:okdrops the error to get anOption, whileok_or/ok_or_elsesupply an error to turn anOptioninto a propagatableResult- the join you will use every time aHashMap::getneeds to become a real error.or_elserecovers on failure, the mirror ofand_then, andcollecton an iterator ofResults gives you a singleResult<Vec<_>, _>that short-circuits on the first error - a batch validator in one word.- The pay-off: with these eight-or-so methods you can express almost any error flow as a flat, readable pipeline, with the type system guaranteeing you handled the failure path. That is the difference between error handling that fights you and error handling that reads like prose.
We leaned on Option combinators here without dwelling on them - and_then, ok_or, unwrap_or_else all have Option twins - and there is a whole ergonomic toolkit on the Option side that deserves its own episode. That, and the natural next question of what to do when a plain String error is not good enough and you want a real, structured error type, is where we go from here.
Exercises
Three exercises, gentle to chewier. Type them yourself before the next episode - combinators only truly click once your own fingers have chained a few.
- Parse a
&strtof64, usemap_errto turn a parse failure into a custom message, and usemapto round the result to the nearest integer - all in one chain, returningResult<i64, String>. - Chain two fallible steps with
and_then: first parse a&strto ausize, then use that as a key into a smallHashMap<usize, &str>, returningok_or"no such key" when the lookup misses. (Hint:HashMap::getreturns anOption.) - Given a
Vec<&str>of numbers where one entry is garbage, use.iter().map(...).collect::<Result<Vec<i32>, _>>()to show it short-circuits to anErr, then swap the garbage for a valid number and show it becomesOkwith all the parsed values.
Bedankt voor het lezen, en tot de volgende! ;-)