Learning Rust · Part IV · Chapter 07

Monads,
without the fear

You have probably already used a monad today. This guide explains what that word actually means, in plain English, with Rust you can run.

01

The problem: values that come with baggage

Some values in a program are simple. 5 is just five. "hello" is just a string. You can pass them around and nothing surprising happens.

But many values come with baggage, an extra circumstance attached to them:

  • A database lookup might find nothing. In Rust that is an Option<User>.
  • Parsing a string might fail. That is a Result<i32, ParseIntError>.
  • A network call is not finished yet. That is a Future<Output = Response>.
  • A search might return many things. That is a Vec<Match> or an iterator.

Rust is honest about baggage. It will not hand you a plain User when the user might not exist; it hands you a box that might contain a user, and forces you to deal with the box.

Option<User>User
Result<T, E>T
Future<T>T

Each wrapper is a box holding a value plus a story about that value: missing, failed, pending, plural.

The honesty is great. The pain starts when you need to do several baggage-carrying steps in a row. Say we want a user's email domain, and every step can come up empty:

the_pyramid_of_doom.rs
fn email_domain(id: u32) -> Option<String> {
    match get_user(id) {
        None => None,
        Some(user) => match get_email(&user) {
            None => None,
            Some(email) => match get_domain(&email) {
                None => None,
                Some(domain) => Some(domain),
            },
        },
    }
}

Three steps, three layers of nesting, and most of the code is just repeating the same boring sentence: "if it was nothing, stay nothing; otherwise keep going." Ten steps would be unreadable.

That repeated sentence is the clue. Whenever the same plumbing shows up between every step of a pipeline, someone has usually already abstracted it. Here, that abstraction has a famous and famously scary name.

02

The pattern: a monad is a chainable box

Here is the same function, written the way an experienced Rust programmer would write it:

flat_and_happy.rs
fn email_domain(id: u32) -> Option<String> {
    get_user(id)
        .and_then(|user| get_email(&user))
        .and_then(|email| get_domain(&email))
}

Same behaviour, no pyramid. All of the "if nothing, stay nothing" plumbing now lives inside and_then, written once in the standard library instead of three times by you.

You just used a monad. Time for the definition.

The actual definition, in plain English

A monad is a wrapper type, call it M<T>, that gives you exactly two abilities:

  1. Wrap: take a plain value and put it in the box. For Option that is Some(x); for Result it is Ok(x).
  2. Chain: take a boxed value and a function that returns a new boxed value, and connect them, letting the box handle its own baggage in between. In Rust this is and_then (the academic name is bind).
In plain English

A monad is a box with a built-in rule for chaining steps that each return another box. The rule deals with the baggage (missing, failed, pending, plural) so your code only has to describe the happy path.

The signature of Option::and_then tells the whole story. Read it slowly once; it is the most important type signature in this document:

std::option (simplified)
impl<T> Option<T> {
    // take: a boxed T, and a function T -> boxed U
    // give back: a boxed U
    fn and_then<U, F>(self, f: F) -> Option<U>
    where
        F: FnOnce(T) -> Option<U>,
    {
        match self {
            Some(x) => f(x),   // open the box, run the next step
            None => None,      // no value? skip the step entirely
        }
    }
}

That five-line match is the entire pyramid of doom, written once, forever. Every call to and_then replaces one layer of nesting.

Why the scary name?

"Monad" comes from category theory, a branch of mathematics. Haskell programmers borrowed it, and the internet filled with confusing metaphors about burritos and spacesuits. You need none of that. Box, wrap, chain. That is the whole concept. The math just proves the pattern behaves predictably (section 07).

03

map vs and_then: the one distinction that matters

Beginners mix these up constantly, and the difference is exactly the difference between "a function" and "a monad". It hinges on one question: does your next step return a plain value, or another box?

Your step returns...UseBecause
a plain value, T -> Umapthe box just transforms its contents
another box, T -> Option<U>and_thenthe boxes need to be merged into one

Watch what goes wrong if you use map with a step that itself returns an Option:

double_boxing.rs
fn get_email(user: &User) -> Option<String> { /* ... */ }

// map wraps the result AGAIN: you get a box in a box
let nested: Option<Option<String>> =
    get_user(7).map(|u| get_email(&u));

// and_then flattens as it goes: one box, as intended
let flat: Option<String> =
    get_user(7).and_then(|u| get_email(&u));

An Option<Option<String>> is almost never what you want. and_then exists precisely to flatten while chaining, which is why other languages call the same operation flatMap. Same idea, different name:

  • Rust: and_then (and flat_map on iterators)
  • JavaScript: Array.flatMap, and .then on promises behaves this way too
  • Haskell: >>=, pronounced "bind"
Rule of thumb

If your closure returns a wrapped value and you used map, the compiler will hand you a double-wrapped type, and that type error is your signal: switch to and_then.

04

Result and ?: the monad you use every day

Result<T, E> is the same pattern as Option, except the empty case carries information: an error value. Its and_then runs the next step on Ok and skips everything on Err, carrying the error to the end of the chain:

result_chain.rs
fn load_port(path: &str) -> Result<u16, ConfigError> {
    read_file(path)                          // Result<String, ConfigError>
        .and_then(|text| parse_toml(&text))  // Result<Toml,   ConfigError>
        .and_then(|toml| get_port(&toml))    // Result<u16,    ConfigError>
}

This is so common that Rust gave it dedicated syntax. The ? operator is monadic chaining dressed up as ordinary, top-to-bottom code:

result_chain_sugar.rs
fn load_port(path: &str) -> Result<u16, ConfigError> {
    let text = read_file(path)?;   // Err? return it now. Ok? unwrap it.
    let toml = parse_toml(&text)?;
    let port = get_port(&toml)?;
    Ok(port)
}

Each ? means: "if this is an error, stop and return it from the whole function; otherwise take the value out of the box and keep going." That is and_then with the closure replaced by the rest of the function body.

The big reveal

Haskell has do notation, JavaScript has async/await, and Rust has ? and .await. All of them are the same trick: syntax that makes a chain of monadic steps look like a list of plain statements. Languages do not adopt sugar for rare patterns. The sugar exists because the pattern is everywhere.

Good to know

? works on Option too: in a function returning Option<T>, writing get_user(id)? returns None early if the user is missing. The first example in this guide can be written with two ?s and no and_then at all.

05

Monads hiding in plain sight

Once you can see the shape (wrap, chain, flatten) you start spotting it all over the standard library.

Iterators: the "many values" box

An iterator is a box whose baggage is plurality: it holds zero or more values. Its chaining operation is flat_map, used when each step produces several results of its own:

iterators.rs
let sentences = vec!["hello world", "monads are boxes"];

// each sentence becomes MANY words: flat_map merges them into one stream
let words: Vec<&str> = sentences
    .iter()
    .flat_map(|s| s.split_whitespace())
    .collect();

assert_eq!(words, ["hello", "world", "monads", "are", "boxes"]);

With map you would get a list of lists. flat_map flattens while chaining, exactly like and_then did for Option. Same pattern, different baggage.

Futures: the "not yet" box

An async value is a box whose baggage is time: the value is not here yet. .await is its version of ?, sugar for "chain the rest of this function onto the box when it is ready":

futures.rs
async fn fetch_avatar(id: u32) -> Result<Image, ApiError> {
    let user  = fetch_user(id).await?;     // wait for the box, then unwrap it
    let image = fetch_image(&user.avatar_url).await?;
    Ok(image)
}

Two monads stacked in one line: .await chains through the "not yet" baggage, and ? chains through the "might have failed" baggage. The code still reads top to bottom.

The boxIts baggageWrapChainSugar
Option<T>might be absentSome(x)and_then?
Result<T, E>might have failedOk(x)and_then?
Iterator / Vec<T>zero or more valuesvec![x]flat_mapfor
Future<T>not ready yetasync { x }.then / polling.await
Why is there no Monad trait in Rust?

In Haskell, all of these share one interface. Rust cannot express that trait today: it would need higher-kinded types, the ability to be generic over the box itself (M<_>) rather than over what is inside it. So Rust has the monad pattern, spelled out separately on each type, rather than a monad abstraction. In day-to-day code this costs you almost nothing; the methods are right there.

06

Build one yourself: a box that keeps a diary

The fastest way to make this stick is to build a monad of your own. Ours will carry new baggage: a log of everything that happened to the value. (Functional programmers call this a writer monad; we will call it Logged.)

A monad needs three things: a wrapper type, a way to wrap, and a way to chain. Here is all of it:

logged.rs
/// A value plus a diary of how it came to be.
struct Logged<T> {
    value: T,
    log: Vec<String>,
}

impl<T> Logged<T> {
    // 1. WRAP: a plain value, with an empty diary
    fn wrap(value: T) -> Self {
        Logged { value, log: Vec::new() }
    }

    // 2. CHAIN: run the next step, then merge the diaries
    fn and_then<U>(self, f: impl FnOnce(T) -> Logged<U>) -> Logged<U> {
        let next = f(self.value);      // open the box, run the step
        let mut log = self.log;        // keep the old diary...
        log.extend(next.log);          // ...append the new entries
        Logged { value: next.value, log }
    }
}

Notice the shape of and_then: it takes self (a boxed T) and a function from T to a boxed U, and returns a boxed U. Identical to Option::and_then; only the baggage-handling in the middle changed. Option short-circuits on None; Logged concatenates diaries.

Now write small steps that each do one thing and record it:

logged_pipeline.rs
fn double(x: i32) -> Logged<i32> {
    Logged { value: x * 2, log: vec![format!("doubled {x}")] }
}

fn add_ten(x: i32) -> Logged<i32> {
    Logged { value: x + 10, log: vec![format!("added ten to {x}")] }
}

fn main() {
    let result = Logged::wrap(4)
        .and_then(double)      // 8,  log: ["doubled 4"]
        .and_then(add_ten)     // 18, log: [.., "added ten to 8"]
        .and_then(double);     // 36, log: [.., "doubled 18"]

    println!("{}", result.value);          // 36
    println!("{:#?}", result.log);
    // [
    //   "doubled 4",
    //   "added ten to 8",
    //   "doubled 18",
    // ]
}

Look at main: it is pure happy path. No step passes the log to the next step; no step even knows the log exists outside its own entry. The chaining rule carries the baggage so the steps do not have to. That sentence is the entire value proposition of monads.

What you just learned

To make your own monad you write: a wrapper struct (value + baggage), a wrap constructor (value + empty baggage), and an and_then that runs the next step and merges the baggage. Three pieces. You can now do this for retries, permissions, undo history, metrics, anything that should ride along a pipeline invisibly.

07

The three rules that keep boxes honest

One last piece. To officially count as a monad, the wrap and chain operations must obey three rules, the monad laws. They sound academic but each one protects an intuition you already rely on when refactoring. With w for wrap and f, g for steps:

i
Wrapping first changes nothing

Putting a value in a fresh box and immediately chaining a step is the same as just calling the step. Wrap must not smuggle in extra baggage.

Logged::wrap(x).and_then(f) == f(x)
ii
Wrapping last changes nothing

Chaining a box into plain wrap gives back an equivalent box. Re-wrapping must not lose or invent baggage.

boxed.and_then(Logged::wrap) == boxed
iii
Grouping does not matter

Chaining f then g is the same whether you think of it as two steps or as one combined step. This is what makes long chains safe to split, inline, and refactor.

boxed.and_then(f).and_then(g) == boxed.and_then(|x| f(x).and_then(g))

You will probably never prove these for your own types, and that is fine. Their practical meaning is simply: a well-behaved and_then has no surprises, so you may reorder, extract, and inline steps in a chain without changing what the program does. Option, Result, iterators, and our Logged all obey them.

08

When to reach for it (and when not to)

SituationReach for
A sequence of steps that can each fail, in a function returning Result or Option? on every step. This is the default; prefer it over explicit chaining.
Transforming the value inside a box without taking it outmap (and friends like map_err, unwrap_or_else)
A step whose own return type is another boxand_then / flat_map, to chain without double-wrapping
One-off expressions where ? is unavailable, e.g. inside a closurean and_then chain
Extra context (logs, metrics, permissions) that should ride along a pipelineyour own wrapper with wrap + and_then, like Logged
Simple branching on one value, no chaina plain match or if let. Do not force monads where a match is clearer.
A matter of taste

Idiomatic Rust leans on ? far more than on long and_then chains. If a chain grows past two or three links, or the closures need move, ownership juggling, or nested chains of their own, rewrite it with let bindings and ?. The monad is still there; you are just using the sugar instead of the raw pattern.

The whole guide in six lines

  1. Some values carry baggage: might be missing, might have failed, not ready, plural.
  2. Rust wraps such values in boxes: Option, Result, iterators, futures.
  3. A monad is a box with two operations: wrap a plain value, and chain (and_then) a step that returns another box.
  4. Chaining flattens: no box-in-a-box. Use map only when the step returns a plain value.
  5. ? and .await are sugar for chaining; you use monads every day already.
  6. To build your own: a struct holding value + baggage, a wrap, and an and_then that merges the baggage. Three pieces, no category theory required.