40 Rust Patterns That Matter
You can read the Rust standard library docs cover-to-cover and still not know how to think in Rust. The docs tell you what each method does. They don't tell you when you'd reach for it, or which three methods combine to solve the problem you're actually staring at.
This is a field guide to forty patterns that show up constantly in real Rust code. Each one is a small decision -- a moment where you know what you want but don't know the idiomatic way to say it. They're grouped by the kind of problem they solve, not by which trait they live on.
I. Errors and Options
These patterns are about control flow. Rust doesn't have exceptions, so
Result and Option are your control flow.
The patterns here are the grammar for saying "this might fail" and "here's
what to do about it."
1. The Success or Bail
You have a list of tasks. If they all succeed, you want the collected results. If any one fails, the whole batch is a failure.
The trick is that collect() knows how to do this. When you
collect an iterator of Result<T, E> into a
Result<Vec<T>, E>, Rust short-circuits on the first
error and gives you back that error. If everything succeeds, you get
Ok(vec_of_results).
let results: Result<Vec<_>, _> = items.iter().map(|x| x.try_into()).collect();
// [Ok(1), Ok(2), Ok(3)] -> Ok([1, 2, 3])
// [Ok(1), Err(e), Ok(3)] -> Err(e)
This replaces the loop-with-early-return pattern you'd write in C. The
FromIterator implementation for Result handles
the bail-out logic internally.
2. The Error Pivot
You're inside a function that returns Result, but you have an
Option. You need to turn "nothing here" into a specific error
so you can use ? to propagate it.
let val = maybe_user.ok_or(Error::NotFound)?;
// Some(user) -> Ok(user)
// None -> Err(NotFound)
ok_or bridges the two types: Some(x) becomes
Ok(x), and None becomes
Err(whatever_you_passed). If constructing the error is
expensive, use ok_or_else with a closure so it only runs on
the None path.
// This builds the error string even when maybe_user is Some:
let val = maybe_user.ok_or(format!("User {} not found", id))?;
// This only builds it when needed:
let val = maybe_user.ok_or_else(|| format!("User {} not found", id))?;
3. The Silent Discard
You have a list of inputs and some of them are garbage. You want to keep the good ones and pretend the bad ones never existed. No error handling, no logging -- just drop them on the floor.
Map the fallible operation to an Option with .ok(),
then use filter_map to keep only the Some values.
let valid: Vec<i32> = inputs.iter().filter_map(|s| s.parse().ok()).collect();
// ["1", "abc", "3"] -> [1, 3]
The .ok() call converts Result<T, E> into
Option<T>, discarding the error. Then filter_map
does a filter-and-unwrap in one step: keep the Somes, skip
the Nones.
4. The Specific Failure Context
An operation failed, but the error is too generic. "File not found" isn't helpful when you know which file and why you were reading it. You want to wrap the error in something more specific without losing the original.
let config = load_file(path)
.map_err(|e| AppError::ConfigLoad { path: path.into(), source: e })?;
// Ok(cfg) -> Ok(cfg)
// Err(io_error) -> Err(AppError::ConfigLoad { path, source: io_error })
map_err transforms only the error side and leaves Ok
values completely untouched. It's the error equivalent of map,
which transforms only the success side.
5. The Fallback Chain
You have multiple places a value might come from. Try the first; if it's
None, try the second; if that's None, try the third.
let color = user_preference
.or(theme_default)
.or(Some(Color::Black));
// Some(Red).or(Some(Blue)).or(Some(Black)) -> Some(Red)
// None.or(None).or(Some(Black)) -> Some(Black)
or on Option returns the first Some
it finds. The same pattern works on Result -- the first
Ok wins. If computing the fallback is expensive, use
or_else with a closure:
let config = load_from_file()
.or_else(|_| load_from_env())
.or_else(|_| load_defaults());
This is a try-one-then-try-another chain, and it reads exactly like one.
6. The Safe Default
A value might be missing, and you're fine with the type's default
(0 for numbers, "" for strings, []
for vecs). You don't want to spell out what the default is because the type
already knows.
let count = maybe_count.unwrap_or_default(); // None -> 0 for integers
let name = maybe_name.unwrap_or_default(); // None -> "" for String
This requires that T implements Default. It's a
small thing, but it eliminates a class of "what should the default be?"
decisions and makes the intent crystal clear: "I want the zero value."
7. The Nested Collapse
You end up with an Option<Option<T>> -- usually
because you mapped over an Option with a function that itself
returns an Option. You want to flatten it down to a single
Option<T>.
let nested: Option<Option<i32>> = Some(Some(42));
let flat: Option<i32> = nested.flatten(); // Some(42)
let also_nested: Option<Option<i32>> = Some(None);
let also_flat: Option<i32> = also_nested.flatten(); // None
This comes up more than you'd expect. Any time you call
.map(|x| something_that_returns_option(x)) you get the double
wrapping. You can either switch to and_then (pattern 21 below)
to avoid it in the first place, or flatten to clean it up after.
8. The Conditional Keep
You have an Option and you want to keep it only if the inner
value passes a test. If it fails the test, turn the whole thing into
None.
let port: Option<u16> = Some(8080);
let valid_port = port.filter(|&p| p > 1024); // Some(8080)
let low_port = Some(80_u16).filter(|&p| p > 1024); // None
filter on Option is the gatekeeper. It either
lets the Some through or downgrades it to None.
Useful for validation chains where you want to keep everything in
Option-land without unwrapping.
9. The Borrow, Don't Move
You have an Option<String> and you want to peek at the
contents without taking ownership. You want Option<&String>
or Option<&str>.
let name: Option<String> = Some("Alice".to_string());
// as_ref: Option<String> -> Option<&String>
if name.as_ref().is_some_and(|s| s.starts_with('A')) {
// name is still usable here
}
// as_deref: Option<String> -> Option<&str> (even better)
match name.as_deref() {
Some("Alice") => println!("Found Alice"),
Some(other) => println!("Found {other}"),
None => println!("Nobody home"),
}
as_ref gives you a reference to whatever's inside.
as_deref goes one step further and dereferences it too, so
Option<String> becomes Option<&str>.
The same pair works on Result: as_ref gives you
Result<&T, &E>.
10. The Map the Wrapper
You have a Result or Option and want to transform
what's inside without unwrapping it. This is the bread-and-butter combinator.
let maybe_len: Option<usize> = maybe_name.map(|s| s.len());
// Some("Alice") -> Some(5)
// None -> None
let parsed: Result<i32, _> = line.map(|s| s.trim().len() as i32);
// Ok(" hello ") -> Ok(5)
// Err(e) -> Err(e)
map on Option transforms Some(x) to
Some(f(x)) and leaves None alone.
map on Result transforms Ok(x) to
Ok(f(x)) and leaves Err alone. It's how you work
with wrapped values without leaving the wrapper.
II. Ownership and Memory
These patterns are about efficiency -- knowing when you're copying data, when you're borrowing it, and when you're consuming it. In C, you'd manage this with conventions and discipline. In Rust, the patterns have names.
11. The Cheap Reference Upgrade
You have an iterator yielding references to small, Copy types
like &i32 or &bool, but the next function
in the chain expects owned values.
let sum: i32 = numbers.iter().copied().sum();
// [1, 2, 3].iter() -> &1, &2, &3 -> .copied() -> 1, 2, 3 -> .sum() -> 6
let flags: Vec<bool> = bits.iter().copied().collect();
// [true, false].iter() -> &true, &false -> .copied() -> true, false
copied() turns &T into T for any
type that implements Copy. It's a memcpy of a few bytes --
trivially cheap. When you see copied() in code, you know
nothing expensive is happening.
12. The Expensive Reference Upgrade
Same situation, but the types involve heap allocations. You have
&String or &Vec<T> and you need
owned versions. You accept the cost.
let owned_names: Vec<String> = name_refs.iter().cloned().collect();
// [&"Alice", &"Bob"] -> ["Alice".clone(), "Bob".clone()] -> heap allocations
cloned() calls .clone() on each element. It
compiles for anything that implements Clone, but unlike
copied(), it might allocate. When you see cloned()
in a review, it's a signal: "heap allocations happening here, and the
author knows it."
13. The Resource Recycle
You're done with a container -- a HashMap, a Vec,
a BTreeSet -- but you need the individual items as owned
values. You want to tear the container apart.
for (key, value) in map.into_iter() {
// key and value are owned -- map is consumed
other_map.insert(key, value);
}
into_iter() consumes the collection and yields owned items.
Compare: .iter() borrows and yields references,
.iter_mut() borrows mutably. The into_ prefix
follows the standard convention -- it eats the original.
14. The Peeking into the Box
You have a String inside an Option and you want
to call string methods on it -- check the length, see if it starts with
something -- without moving it out.
// as_deref: Option<String> -> Option<&str>
let is_empty = maybe_name.as_deref().map_or(true, |s| s.is_empty());
let starts_with_a = maybe_name.as_deref()
.is_some_and(|s| s.starts_with('A'));
as_deref() uses Deref coercion to give you a
borrowed view: Option<String> becomes
Option<&str>,
Option<Vec<T>> becomes
Option<&[T]>,
Option<Box<T>> becomes
Option<&T>. You get to peek without taking anything out.
15. The Temporary Theft
You have an Option inside a mutable struct and you need to take
the value out, leaving None behind. You're not borrowing --
you're stealing, but you're leaving the struct in a valid state.
struct Connection {
pending_message: Option<Message>,
}
impl Connection {
fn flush(&mut self) -> Option<Message> {
self.pending_message.take() // Returns the Message, leaves None
}
}
Option::take() swaps the contents with None and
gives you what was there. It's the clean way to move a value out of a
mutable reference without unsafe. You'll see this constantly in
state machines, buffered I/O, and event systems.
16. The Hot Swap
You need to replace a value but also need the old one back. You can't just assign because you need both the old and new values.
use std::mem;
let mut current = State::Running;
let previous = mem::replace(&mut current, State::Paused);
// current is now Paused, previous is Running
mem::replace puts a new value in and hands you the old one.
It's how you do atomic-feeling swaps without Clone and without
leaving the variable in an undefined state. Useful any time you need to
transition state and react to what the state was.
17. The Drain and Keep
You need to remove some elements from a Vec, but you want both
the removed elements and the remaining vec -- and you don't want
to clone anything.
let mut items = vec![1, 2, 3, 4, 5];
// drain a range: removes and yields elements 1..3
let removed: Vec<i32> = items.drain(1..3).collect();
// removed = [2, 3], items = [1, 4, 5]
// drain all: empties the vec but keeps the allocation
let all: Vec<i32> = items.drain(..).collect();
// all = [1, 4, 5], items = [] (but buffer is still allocated)
drain is the surgical removal tool. It removes a range
in-place, yields the removed elements as an iterator, and shifts remaining
elements to fill the gap. The vec's heap allocation is preserved, which
matters when you're draining and refilling in a loop.
18. The Cow Dance
You have a function that sometimes needs to modify a string and sometimes doesn't. You don't want to clone every time "just in case."
use std::borrow::Cow;
fn normalize(input: &str) -> Cow<str> {
if input.contains('\t') {
Cow::Owned(input.replace('\t', " "))
} else {
Cow::Borrowed(input) // No allocation needed
}
}
// normalize("hello") -> Cow::Borrowed("hello") -- zero-cost
// normalize("he\tllo") -> Cow::Owned("he llo") -- allocates
Cow -- "clone on write" -- is either a reference or an owned
value. It lets you defer the allocation decision. When most inputs don't
need modification, this avoids the heap allocation entirely. You'll see it
in parsers, config processors, and anywhere strings are conditionally
transformed.
III. Iterators
Iterators are Rust's data processing pipeline. These patterns are the individual pipe fittings -- each one solves a specific "I have this, I want that" transformation.
19. The Search and Transform
You want to find the first item in a collection that matches some condition, and then immediately extract or transform a value from it.
let first_admin_email = users.iter()
.find_map(|u| if u.is_admin { Some(&u.email) } else { None });
// [{admin: false}, {admin: true, email: "a@b.com"}, ...] -> Some("a@b.com")
find_map combines find and map into
one pass. Your closure returns Some(value) to say "found it,
and here's what I want from it" or None to say "keep looking."
It short-circuits on the first Some.
20. The Dynamic Boundary
You're processing a stream of data and you need to stop the moment a condition is no longer true. Not "skip the ones that fail" -- stop entirely.
// Read header lines until the first blank line
let headers: Vec<_> = lines.iter()
.take_while(|l| !l.is_empty())
.collect();
// ["Host: x", "Accept: y", "", "body"] -> ["Host: x", "Accept: y"]
take_while is a fence, not a filter. Once the predicate returns
false, the iterator is done -- even if later elements would
pass. This is what you want for things with structure: headers before a blank
line, sorted runs, leading whitespace.
21. The Custom Rank
You want the "best" item in a list, where "best" is defined by a property you can extract -- the largest file, the most recent date, the shortest name.
let largest = files.iter().max_by_key(|f| f.size_bytes());
let most_recent = events.iter().max_by_key(|e| e.timestamp);
let shortest_name = users.iter().min_by_key(|u| u.name.len());
max_by_key and min_by_key let you point at a
field or derived value and say "compare by this." No need to implement
Ord on the whole struct. If you need more complex comparison
logic (multi-field tiebreaking), use max_by with a full
comparator closure instead.
22. The Nested Flattening
You have a list of things, each containing its own list. You want one flat list of all the sub-items.
let all_tags: Vec<&str> = posts.iter()
.flat_map(|p| &p.tags)
.collect();
// [{tags: ["rust", "cli"]}, {tags: ["wasm"]}] -> ["rust", "cli", "wasm"]
flat_map is map followed by flatten.
Each element produces an iterator (or something that can become one), and
the results are concatenated into a single stream. It's the iterator
equivalent of a nested loop, flattened.
23. The Index-Aware Transformation
You're processing a list and you need to know each element's position -- for numbering output, building an index, or treating the first/last element differently.
for (i, line) in lines.iter().enumerate() {
println!("{:4}: {}", i + 1, line);
}
// Or in a chain:
let numbered: Vec<String> = items.iter()
.enumerate()
.map(|(i, item)| format!("{}. {}", i + 1, item))
.collect();
// ["apple", "banana"] -> ["1. apple", "2. banana"]
enumerate() wraps each element in a (index, element)
tuple, starting from zero. It's the Rust equivalent of the
"for i, item in enumerate(items)" pattern from Python.
24. The Collection Partition
You want to split one list into two: things that pass a test and things that fail it. Both halves matter.
let (passing, failing): (Vec<_>, Vec<_>) = tests.iter()
.partition(|t| t.score >= 70);
// scores [95, 40, 70, 55] -> passing: [95, 70], failing: [40, 55]
partition takes a predicate and returns a tuple of two
collections. Every element goes into exactly one of them. Note the type
annotation -- Rust needs to know what kind of collections to build, since
partition works with any FromIterator type.
25. The Double-Ended Extraction
You need the first and last elements of a collection. Not the min and max -- the actual first and last by position.
let mut it = items.iter(); // items = [10, 20, 30]
let first = it.next(); // Some(&10)
let last = it.next_back(); // Some(&30)
This works because slice::Iter implements
DoubleEndedIterator, which gives you next_back().
The two calls don't interfere -- next pulls from the front,
next_back pulls from the back. If the collection has only one
element, last will be None.
26. The Pair Walk
You have two lists of the same length and you want to process them element-by-element in lockstep.
let names = vec!["Alice", "Bob", "Carol"];
let scores = vec![95, 87, 92];
let report: Vec<String> = names.iter()
.zip(scores.iter())
.map(|(name, score)| format!("{name}: {score}"))
.collect();
// -> ["Alice: 95", "Bob: 87", "Carol: 92"]
zip pairs up elements from two iterators, producing
(a, b) tuples. It stops when the shorter iterator runs out --
no panics, no padding, just a clean stop. If you need to know about the
leftovers, look at zip_longest from the itertools
crate.
27. The Chain Link
You have two collections of the same type and you want to process them as one continuous sequence.
let defaults = vec!["debug", "info"];
let overrides = vec!["warn", "error"];
let all_levels: Vec<_> = defaults.iter()
.chain(overrides.iter())
.collect();
// ["debug", "info", "warn", "error"]
chain appends one iterator after another. When the first runs
out, it seamlessly switches to the second. You can chain as many as you
want: a.chain(b).chain(c). It's lazy -- nothing happens until
you consume the chained iterator.
28. The Existence Probe
You don't need the matching element. You just need to know: does any element match? Or do all of them match?
let has_errors = results.iter().any(|r| r.is_err());
// [Ok(1), Err(e), Ok(3)] -> true (stops at Err)
let all_positive = numbers.iter().all(|&n| n > 0);
// [3, -1, 5] -> false (stops at -1)
Both any and all short-circuit. any
returns true the moment it finds a match and stops.
all returns false the moment it finds a non-match
and stops. They're the iterator equivalent of || and
&& across a collection.
29. The Accumulator
You need to reduce a collection down to a single value, and the reduction is more complex than a simple sum or product.
// Build a frequency map
let freq = words.iter().fold(HashMap::new(), |mut map, word| {
*map.entry(word).or_insert(0) += 1;
map
});
// Concatenate with a separator
let csv = fields.iter().fold(String::new(), |mut acc, field| {
if !acc.is_empty() { acc.push(','); }
acc.push_str(field);
acc
});
fold takes an initial value (the "accumulator") and a closure
that combines the accumulator with each element. It's the general-purpose
reduction -- sum, product, count,
min, and max are all special cases of
fold. If you don't need an initial value, reduce
starts with the first element instead.
30. The Windowed View
You need to look at consecutive groups of elements -- pairs, triples, or sliding windows of any size.
let data = vec![1, 2, 3, 4, 5];
// Sliding windows of size 2
for pair in data.windows(2) {
println!("{} -> {}", pair[0], pair[1]);
}
// 1 -> 2, 2 -> 3, 3 -> 4, 4 -> 5
// Fixed-size chunks (non-overlapping)
for chunk in data.chunks(2) {
println!("{:?}", chunk);
}
// [1, 2], [3, 4], [5]
windows gives you overlapping slices -- every contiguous group
of n elements. chunks gives you non-overlapping
slices -- the list cut into pieces of size n (the last one might
be shorter). Both return slices, not copies.
31. The Unique Run
You have a sorted (or partially sorted) list and you want to remove consecutive duplicates.
let mut data = vec![1, 1, 2, 3, 3, 3, 4];
data.dedup();
// [1, 2, 3, 4]
// Dedup by a key (e.g., ignore case)
let mut words = vec!["Hello", "hello", "World", "world"];
words.dedup_by_key(|s| s.to_lowercase());
// ["Hello", "World"]
dedup removes consecutive duplicates in place. It doesn't sort
first -- it only collapses runs of identical values. If you need
globally unique values, sort first, then dedup. Or use a HashSet
if order doesn't matter.
32. The Position Finder
You don't need the element itself. You need to know where it is.
let idx = haystack.iter().position(|&x| x == needle);
// Some(3) or None
let last_idx = haystack.iter().rposition(|&x| x == needle);
// searches from the end
position returns the index of the first match.
rposition returns the index of the last match (searching
backward). Both return Option<usize> --
None if no element matches. These are the iterator equivalent
of indexOf from other languages, but with a predicate instead
of just equality.
IV. Composition
These patterns are about combining operations cleanly -- chaining fallible operations, injecting side effects without breaking chains, and flipping container types to match what the surrounding code expects.
33. The Fallible Map
You have a Result and you want to run another operation that
could also fail. If you use map, you end up with
Result<Result<T, E>, E> -- a nested mess. You
want flat chaining.
let content = find_file(name)
.and_then(|path| read_to_string(path))?;
// Ok("config.toml") -> read_to_string("config.toml") -> Ok("key = val")
// Err(e) -> Err(e) (skips read_to_string entirely)
and_then is the monadic bind for Result (and
Option). If the first operation succeeds, it feeds the value
to the next function. If it fails, it short-circuits with the error. It's
map but the closure itself returns a Result, and
the nesting is flattened automatically.
On Option, and_then does the same thing -- it's
how you chain functions that return Option without ending up
with Option<Option<T>>.
34. The In-Place Wrapper Removal
You used a BufReader, a GzDecoder, or some other
wrapper type to add behavior to an inner resource. Now you're done with the
wrapper and want the original back.
let file = buf_reader.into_inner();
let socket = tls_stream.into_inner();
into_inner() is the standard Rust name for "give me the thing
inside this wrapper." The into_ prefix tells you it consumes
the wrapper. Any buffered data is not flushed -- if that matters,
call flush() first.
35. The Peek and Poke
You're building a long method chain and something isn't working. You want to see what's flowing through at a specific point without breaking the chain.
let total = items.iter()
.filter(|x| x.is_valid())
.inspect(|x| eprintln!("after filter: {x:?}"))
.map(|x| x.value())
.inspect(|v| eprintln!("after map: {v}"))
.sum::<i32>();
inspect passes a reference to each element to a closure, then
passes the element through unchanged. It's println-debugging
for iterator chains. In production code, you'll see it with logging instead
of printing.
On Result, there's inspect and
inspect_err for peeking at the success and error sides
independently.
36. The Type Flip
You have an Option<Result<T, E>> -- maybe from a
function that optionally does something fallible. But your function returns
Result, so you want
Result<Option<T>, E> instead, so you can use
? on the outer layer.
fn get_config_value(key: &str) -> Result<Option<String>, ConfigError> {
let maybe_raw: Option<Result<String, ConfigError>> = map.get(key).map(parse);
maybe_raw.transpose()
// Option<Result<T, E>> -> Result<Option<T>, E>
}
transpose swaps the container types. If the Option
is None, you get Ok(None). If it's
Some(Ok(v)), you get Ok(Some(v)). If it's
Some(Err(e)), you get Err(e). It works in the
other direction too --
Result<Option<T>, E> back to
Option<Result<T, E>>.
37. The Lazy Default
If a value is missing, you need a default -- but that default involves a heap allocation or a computation you don't want to do unless you have to.
// This allocates every time, even when the Option is Some:
let name = opt_name.unwrap_or(String::from("Anonymous"));
// This only allocates when the Option is None:
let name = opt_name.unwrap_or_else(|| String::from("Anonymous"));
The _or vs _or_else distinction is one of Rust's
core naming conventions (covered in the
companion post). Eager vs
lazy. Value vs closure. Whenever the fallback involves allocation, I/O, or
anything more than returning a literal, reach for the _else
variant.
38. The Slot Machine
You're building up a HashMap and you need to handle the
"insert if missing, update if present" logic cleanly. The entry API gives
you a handle to a slot, and then you decide what to do with it.
use std::collections::HashMap;
let mut counts: HashMap<&str, i32> = HashMap::new();
for word in words {
*counts.entry(word).or_insert(0) += 1;
}
// or_insert_with for expensive defaults:
let mut cache: HashMap<String, Vec<Data>> = HashMap::new();
let items = cache.entry(key.clone())
.or_insert_with(|| load_from_disk(&key));
entry() returns an Entry enum -- either
Occupied or Vacant. Then or_insert
fills the vacant slot, or or_insert_with computes the value
lazily. The whole thing gives you a mutable reference to the value, whether
it was just inserted or already there. It's a single hash lookup instead of
the two (check then insert) you'd need otherwise.
39. The Turbofish
Rust can usually infer types, but sometimes it can't. You need to tell it
explicitly, and the syntax for that looks like a fish:
::<Type>.
let x = "42".parse::<i32>()?; // Ok(42)
let nums = vec![1, 2, 3].into_iter().collect::<HashSet<_>>(); // {1, 2, 3}
let total = values.iter().sum::<f64>(); // e.g. 10.5
The turbofish appears when a method is generic and the return type isn't
constrained by context. parse can return anything that
implements FromStr, collect can build anything
that implements FromIterator, and sum can return
any numeric type. The turbofish disambiguates. The alternative is a type
annotation on the variable: let x: i32 = "42".parse()?; --
same effect, different style.
40. The Scope Guard
You need to run cleanup code when a value is dropped, regardless of whether
the function returns normally or panics. Rust's Drop trait is
the tool, and it works even on early returns from ?.
struct TempFile(PathBuf);
impl Drop for TempFile {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.0);
}
}
fn process() -> Result<(), Error> {
let tmp = TempFile(create_temp_file()?);
do_something_fallible()?; // if this fails, tmp is still cleaned up
do_another_thing()?; // same here
Ok(())
// tmp dropped here on success
}
This is Rust's answer to try/finally, defer, and
RAII. The Drop implementation runs when the value goes out of
scope -- whether that's at the end of the function, after a ?
early return, or during a panic unwind. You don't call it. You can't forget
it.
Putting It All Together
Knowing forty patterns individually is like knowing forty words in a foreign language. You can point at things. You can't hold a conversation. The real skill is seeing how they combine -- recognizing that the problem in front of you is a three-pattern problem, and knowing which three.
Let's work through this in layers, from small combinations to real-world functions, and then look at how you'd arrive at these combinations yourself.
Small Combinations: Two or Three Patterns
Validate and collect. You have raw string inputs. You want the valid ones parsed, the garbage silently dropped, and a count of how many you kept.
let valid: Vec<i32> = raw_inputs.iter()
.filter_map(|s| s.parse().ok()) // #3: Silent Discard
.collect();
// ["10", "abc", "20", ""] -> [10, 20]
let kept = valid.len(); // 2
Two patterns. The Silent Discard (#3) does the filtering and parsing in one
step. collect gathers the survivors. If you'd written this as a loop,
you'd have a mutable vec, a match statement, and a counter. Here, the intent
is one line.
Lookup with a computed fallback. You're checking a cache. If the key isn't there, you compute the value (which involves I/O), store it, and return it.
let entry = cache.entry(key.clone()) // #38: Slot Machine
.or_insert_with(|| fetch_from_db(&key)); // #37: Lazy Default
The Slot Machine (#38) gives you a handle to the map slot. The Lazy Default
(#37) ensures you only hit the database on a cache miss. One hash lookup,
zero redundant I/O. The _with suffix is doing real work here -- without
it, you'd call fetch_from_db on every access regardless.
Find the best match and borrow its data. You want the user with the highest score, and you want their name -- but you don't want to take ownership of anything.
let top_name = users.iter()
.max_by_key(|u| u.score) // #21: Custom Rank
.map(|u| u.name.as_str()); // #10: Map the Wrapper + borrowing
// [{name: "Alice", score: 90}, {name: "Bob", score: 95}] -> Some("Bob")
Custom Rank (#21) finds the winner. Map the Wrapper (#10) reaches inside
the Option to extract what you actually care about. The result is
Option<&str> -- no cloning, no ownership transfer.
Medium Scale: The Daily Function
Here's a function you'd actually write. You're loading plugin configs from a directory. Some files might not exist (that's fine -- they're optional). Some might exist but contain garbage (that's an error). You want the valid configs, with the bad files reported.
fn load_plugins(dir: &Path) -> Result<Vec<PluginConfig>, AppError> {
let entries: Vec<_> = std::fs::read_dir(dir)
.map_err(|e| AppError::DirRead { path: dir.into(), source: e })? // #4: Specific Failure Context
.filter_map(|entry| entry.ok()) // #3: Silent Discard (bad dir entries)
.filter(|entry| {
entry.path()
.extension()
.and_then(|ext| ext.to_str()) // #33: Fallible Map
.is_some_and(|ext| ext == "toml") // #8: Conditional Keep (conceptually)
})
.collect();
entries.iter()
.map(|entry| {
let path = entry.path();
let text = std::fs::read_to_string(&path)
.map_err(|e| AppError::FileRead { // #4: Specific Failure Context
path: path.clone(), source: e
})?;
toml::from_str(&text)
.map_err(|e| AppError::ParseConfig { // #4 again
path: path.clone(), source: e
})
})
.collect() // #1: Success or Bail
}
Count the patterns: Silent Discard for filesystem noise, Specific Failure Context twice (once for I/O, once for parsing), Fallible Map to chain the extension check, and Success or Bail at the end to turn the iterator of Results into a single Result.
Now look at what you didn't write. No nested match statements. No mutable error vec. No boolean flag tracking whether something went wrong. The control flow is declared, not managed.
Across Pattern Groups: Ownership Meets Iteration Meets Error Handling
The interesting functions are the ones where patterns from different sections
collide. Here's a config merger: you have a HashMap of default settings
and a HashMap of user overrides. If the user's value requires
normalization (which can fail), you want to normalize it. Otherwise, fall
back to the default. Bad user values should report errors, not silently
corrupt the config.
fn merge_config(
defaults: &HashMap<String, String>,
overrides: HashMap<String, String>, // owned -- we're consuming this
) -> Result<HashMap<String, String>, ConfigError> {
let mut merged = defaults.clone();
for (key, raw_value) in overrides.into_iter() { // #13: Resource Recycle
let normalized = normalize(&raw_value)
.map_err(|e| ConfigError::BadOverride { // #4: Specific Failure Context
key: key.clone(),
source: e,
})?;
merged.entry(key) // #38: Slot Machine
.and_modify(|v| *v = normalized.clone())
.or_insert(normalized);
}
Ok(merged)
}
Resource Recycle (#13) consumes the overrides map so we get owned keys and values -- no unnecessary cloning of strings we already own. Specific Failure Context (#4) attaches the offending key to any normalization error. The Slot Machine (#38) handles both "key exists, update it" and "key is new, insert it" without a double lookup.
The Conditional Processing Pipeline
Here's a pattern that shows up in anything that processes heterogeneous data: events, log lines, HTTP requests. You have a stream of items, some need transformation, some pass through unchanged, and you want to avoid allocating when you don't have to.
use std::borrow::Cow;
fn process_log_lines<'a>(lines: &'a [String]) -> Vec<Cow<'a, str>> {
lines.iter()
.filter(|line| !line.is_empty()) // skip blanks
.enumerate() // #23: Index-Aware
.map(|(i, line)| {
if line.contains('\t') {
Cow::Owned(format!("[{}] {}", i, line.replace('\t', " "))) // #18: Cow Dance
} else {
Cow::Borrowed(line.as_str()) // #18: no allocation
}
})
.collect()
}
The Cow Dance (#18) means that clean lines -- which are most of them -- involve zero heap allocation. Only the lines that need modification pay for it. The Index-Aware pattern (#23) gives us line numbers for the reformatted output. If this were processing a million log lines, the difference between "clone everything" and "clone only what changes" is measured in milliseconds and megabytes.
The Before and After
Patterns matter most when you see what code looks like without them. Here's a function written by someone who knows Rust syntax but hasn't internalized the patterns:
// Before: manual, verbose, fragile
fn find_best_valid_score(entries: &[Entry]) -> Result<Option<i64>, ScoreError> {
let mut best: Option<i64> = None;
for entry in entries {
match entry.raw_score {
Some(raw) => {
match validate_score(raw) {
Ok(valid) => {
match best {
Some(current) if valid > current => best = Some(valid),
None => best = Some(valid),
_ => {}
}
}
Err(e) => return Err(ScoreError::Validation {
id: entry.id, source: e
}),
}
}
None => {} // skip entries without scores
}
}
Ok(best)
}
Three levels of nesting. A mutable accumulator. An empty match arm that
exists only because match is exhaustive. It works, but it makes you
trace every branch to understand what it does.
Now the same function with patterns:
// After: declarative, flat, same behavior
fn find_best_valid_score(entries: &[Entry]) -> Result<Option<i64>, ScoreError> {
entries.iter()
.filter_map(|e| { // #3: Silent Discard (None scores)
e.raw_score.map(|raw| (e.id, raw)) // #10: Map the Wrapper
})
.map(|(id, raw)| {
validate_score(raw)
.map_err(|e| ScoreError::Validation { // #4: Specific Failure Context
id, source: e
})
})
.collect::<Result<Vec<_>, _>>()? // #1: Success or Bail
.into_iter() // #13: Resource Recycle
.max() // largest valid score
.pipe(Ok) // wrap in Ok -- or just:
// Ok(scores.into_iter().max()) // if you collected first
}
Or, more practically:
fn find_best_valid_score(entries: &[Entry]) -> Result<Option<i64>, ScoreError> {
let valid_scores: Vec<i64> = entries.iter()
.filter_map(|e| e.raw_score.map(|raw| (e.id, raw))) // #3 + #10
.map(|(id, raw)| {
validate_score(raw)
.map_err(|e| ScoreError::Validation { id, source: e }) // #4
})
.collect::<Result<Vec<_>, _>>()?; // #1
Ok(valid_scores.into_iter().max())
}
Same logic. Zero nesting. No mutable state. The error handling is precise (which entry failed, and why) without being verbose. And every step in the chain has a name you can point at: "that's a Silent Discard, that's a Specific Failure Context, that's a Success or Bail."
How You Get There: The Decision Process
When you're staring at a function and wondering which patterns apply, there are three questions worth asking:
1. What shape is my data, and what shape do I need?
If you have a collection and want a single value, you're in fold/reduce
territory (#29). If you have a collection and want a filtered or
transformed collection, you're in the iterator section. If you have a
single wrapped value and want to transform what's inside, that's map
(#10) or and_then (#33).
2. What can go wrong, and who needs to know?
If failure means "skip this item," that's Silent Discard (#3). If failure
means "abort everything," that's Success or Bail (#1). If failure means
"add context and propagate," that's Specific Failure Context (#4). If you
have an Option but the caller expects a Result, that's Error Pivot
(#2). These decisions compose: within a single chain, different steps can
handle failure differently.
3. Who owns what?
If you're done with a collection and need its contents, consume it with
into_iter (#13). If you're peeking at data inside an Option or smart
pointer, use as_deref (#14). If most inputs won't need modification,
consider Cow (#18). If you need to take a value out of a mutable struct,
that's take (#15). Ownership questions determine whether you're
cloning, borrowing, or moving -- and the wrong choice shows up as either a
compiler error or a performance problem.
These three questions usually narrow you down to five or six candidate
patterns. From there, it's a matter of which ones compose cleanly for
your specific case. And that part gets fast with practice -- once you've
seen the Slot Machine solve the "insert or update" problem three times,
you stop even thinking about it. Your fingers type entry().or_insert
the way a C programmer types if (ptr == NULL).
The Bigger Point
None of these patterns are clever. That's the whole point. Each one is a small, obvious decision made explicit. The compound effect of making forty small obvious decisions instead of one large clever one is code that reads like a description of what it does rather than a set of instructions for how to do it.
That's what it means to think in Rust. Not "memorize the Iterator trait." Not "fight the borrow checker." Just: know the forty small decisions, and recognize which ones your function is making.
Telex