Telex logo Telex

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.