Monads — Plain English (Middle)¶
Roadmap: Functional Programming → Monads — Plain English
A monad is a small, boring interface — a way to wrap a value and a way to chain operations that produce wrapped values — plus three sanity rules so the chaining never surprises you.
Optional,Result,List, andPromiseare all the same shape wearing different clothes.
Table of Contents¶
- Introduction
- Prerequisites
- The Monad Interface:
unit+bind - The Three Laws (Informally)
- A Tour of Common Monads
- State, Reader, Writer — a Gentle Intro
- do-notation / for-comprehension: Flattening Sugar
- Railway-Oriented Programming
- Trade-offs: When Monadic Style Clarifies vs Obscures
- Common Mistakes
- Test Yourself
- Cheat Sheet
- Summary
- Further Reading
- Related Topics
Introduction¶
Focus: using it well. At the junior level you saw why
Promise,Optional, andResultfeel related. Here you learn the shared interface, the laws that keep it predictable, and the everyday practice — chaining, do-notation, and railway-oriented error handling — across Java, Python, and Go.
The word "monad" scares people because the math is presented before the use. We do the reverse. A monad is two functions and three rules:
- a way to put a plain value into a box (
unit, also spelledof/return/pure), and - a way to chain a box-producing step onto a box (
bind, also spelledflatMap/>>=), so the boxes never nest.
That's it. Everything else — Optional.flatMap, Stream.flatMap, CompletableFuture.thenCompose, Result.and_then, list comprehensions, async/await — is a re-skin of that interface. Once you see the shape, a dozen APIs collapse into one idea, and you stop writing the same nesting-and-unwrapping boilerplate by hand.
The practical payoff at this level: you can describe a multi-step computation that might fail, be absent, be asynchronous, or produce many results — as a flat sequence of steps, with the boxing concern handled once instead of at every line.
Prerequisites¶
- Required:
junior.md— you can recognize thatOptional,Result,Promise, andList"wrap" a value, and you've usedmapon at least one. - Required: Map / Filter / Reduce —
mapover a container is the gateway concept;bindis its more powerful sibling. - Required: Algebraic Data Types —
Option/Eitherare sum types; pattern matching on them is how a monad is implemented underneath. - Helpful: Composition —
bindis what lets you compose functions that return wrapped values. - Helpful: Currying & Partial Application — useful for the function-shapes that appear in
bind.
The Monad Interface: unit + bind¶
A monad is a generic type M<A> (a box holding an A) equipped with two operations.
| Operation | Common names | Type, in words |
|---|---|---|
| unit | of, return, pure, just | takes a plain A, gives back M<A> |
| bind | flatMap, then, and_then, >>= | takes an M<A> and a function A -> M<B>, gives back M<B> |
The signature of bind is the whole story:
bind : M<A> × (A -> M<B>) -> M<B>
the box a step that the result,
itself returns still one box deep
a box
The crucial word is flat. A step A -> M<B> produces its own box. Naive map would give you M<M<B>> — a box in a box. bind flattens that one layer automatically. That single behavior — chain a box-returning step without accumulating nested boxes — is what makes a monad more than a plain "mappable" container.
map vs bind, concretely¶
map : M<A> × (A -> B) -> M<B> // step returns a plain value
bind : M<A> × (A -> M<B>) -> M<B> // step returns a box; bind flattens
- Use
mapwhen the step can't fail and isn't itself wrapped:Optional<int>.map(x -> x + 1). - Use
bindwhen the step itself returns the same kind of box:Optional<User>.flatMap(u -> findManager(u))wherefindManagerreturnsOptional<User>.
If you ever find yourself with an Optional<Optional<X>> or a List<List<X>> you didn't want, you reached for map where you needed bind.
Why unit matters¶
unit is the way in. It lets you start a chain from a plain value, and it's what a step returns when it has "nothing special" to say — a present Optional, an Ok result, an already-resolved Promise. Without unit you couldn't lift ordinary code into the monad, so the boundary between "plain world" and "boxed world" would have no door.
The Three Laws (Informally)¶
The laws aren't bureaucracy. They are the guarantees that let you refactor monadic code the way you refactor arithmetic — moving pieces around, extracting steps, reordering grouping — without changing the result. Every well-behaved monad obeys them; if a custom one doesn't, chaining will betray you in subtle ways.
1. Left identity — unit then bind is just calling the function¶
bind(unit(x), f)is the same asf(x).
Wrapping a plain value and immediately chaining a step is the same as just running the step on the value. Practically: unit adds no behavior of its own. Putting a value in the box and pulling it through one step shouldn't differ from handing the value straight to the step.
2. Right identity — binding unit changes nothing¶
bind(m, unit)is the same asm.
If the step you chain is "just re-box it" (unit), you get the original box back untouched. Practically: a no-op step is a no-op. someOptional.flatMap(Optional::of) returns the same Optional.
3. Associativity — grouping of chained steps doesn't matter¶
bind(bind(m, f), g)is the same asbind(m, x -> bind(f(x), g)).
Whether you chain f then g, or bundle "f then g" into one combined step and chain that — same result. Practically: you can extract a run of chained steps into a helper function and the meaning is unchanged. This is exactly what lets a.flatMap(f).flatMap(g) read top-to-bottom like a script, and lets you pull .flatMap(f).flatMap(g) out into doFandG.
The one-sentence takeaway: left/right identity say unit is a true do-nothing wrapper; associativity says chaining is order-of-grouping-agnostic, so a chain is a sequence you can read and refactor linearly. When
async/awaitor aResultchain "just reads like normal code," these laws are the reason.
A subtle real-world consequence: a Promise/Future that fires its side effect at construction time (eager) technically violates the spirit of these laws (when you call unit matters), which is one reason purists distinguish lazy IO/Task from eager Promise. For everyday use the chaining still behaves; just know that's why the distinction exists — see Effect Tracking.
A Tour of Common Monads¶
Each entry below names the box, says what "chaining" means for it, and shows whether Java, Python, and Go give you bind directly.
Maybe / Option — the value might be absent¶
bind = "if present, run the next step; if absent, short-circuit and stay absent." It removes manual null/None checks between every step.
// Java — Optional has both map and flatMap
Optional<Address> shipping =
findUser(id) // Optional<User>
.flatMap(User::primaryOrder) // step returns Optional<Order> -> flatMap
.map(Order::shippingAddress);// step returns Address (plain) -> map
// Any None along the way => the whole result is Optional.empty(), no NPE.
# Python — no Optional monad in the stdlib; the idiomatic substitute is `None` + checks,
# or a small library (e.g. `returns`). Manual version:
def shipping(uid):
user = find_user(uid)
if user is None: return None
order = user.primary_order()
if order is None: return None
return order.shipping_address
# Python has no built-in flatMap on Optional; you write the short-circuit by hand
# (or adopt a Maybe type). This is the "doesn't support chaining natively" case.
// Go — no Option type, no generics-based flatMap in the stdlib.
// The idiom is the comma-ok / explicit pointer-nil check, step by step:
user, ok := findUser(id)
if !ok { return Address{}, false }
order, ok := user.PrimaryOrder()
if !ok { return Address{}, false }
return order.ShippingAddress, true
// Go deliberately favors explicit checks over a Maybe monad.
Either / Result — the step succeeds or fails with a reason¶
bind = "if Ok, run the next step; if Err, short-circuit carrying the error." Unlike Optional, the failure carries information. This is the backbone of railway-oriented programming.
// Java has no stdlib Either, but the same shape is common via libraries (Vavr's Either)
// or built by hand. Sketch with Vavr:
Either<Error, Receipt> result =
parse(input) // Either<Error, Cart>
.flatMap(Cart::validate) // Either<Error, Cart>
.flatMap(this::charge) // Either<Error, Receipt>
.map(Receipt::finalize); // Either<Error, Receipt>
# Python — most code uses exceptions instead of a Result monad. The functional substitute
# is a tagged result (a tuple, a dataclass, or the `returns` library's Result):
def charge(cart):
try:
receipt = gateway.charge(cart)
return ("ok", receipt)
except PaymentError as e:
return ("err", e)
# No native flatMap; you branch on the tag. Exceptions are the mainstream Python style.
// Go — the canonical idiom IS error-as-value, but without flatMap: the explicit chain.
cart, err := parse(input)
if err != nil { return nil, err }
if err := cart.Validate(); err != nil { return nil, err }
receipt, err := charge(cart)
if err != nil { return nil, err }
return receipt, nil
// This is the railway, written out by hand. `if err != nil` is Go's `bind`,
// done explicitly on purpose for readability and to keep control flow visible.
Go is the clearest example of a language that rejects implicit monadic chaining for errors and absence, trading conciseness for an always-visible control flow. The pattern is the same; only the sugar is absent.
List — the step produces zero or more results¶
bind = "apply the step to each element and concatenate the resulting lists" (a.k.a. flatMap / concatMap). It models nondeterminism — "for each x, here are the possible y's."
// Java Streams: flatMap concatenates the sub-streams.
List<Pair> pairs = customers.stream()
.flatMap(c -> c.orders().stream() // each customer -> a stream of orders
.map(o -> new Pair(c, o))) // pair each order with its customer
.toList();
# Python: nested comprehension is list-monad bind in disguise.
pairs = [(c, o) for c in customers for o in c.orders]
# `for c ... for o ...` == customers.flatMap(c -> c.orders.map(o -> (c, o)))
// Go: an explicit double loop — the flattening is manual append.
var pairs []Pair
for _, c := range customers {
for _, o := range c.Orders {
pairs = append(pairs, Pair{c, o})
}
}
Future / Promise — the value arrives later¶
bind = "when this completes, feed its result to the next async step." It linearizes callback chains. Note: most Promise/Future types are eager (the work starts immediately), which is why they're "monad-like" rather than textbook-pure.
// Java CompletableFuture: thenCompose IS flatMap (step returns another future);
// thenApply is map (step returns a plain value).
CompletableFuture<Receipt> f =
fetchCart(id) // CompletableFuture<Cart>
.thenCompose(this::charge) // step returns CompletableFuture<Receipt> -> flatMap
.thenApply(Receipt::finalize); // plain -> map
# Python async/await is do-notation for the coroutine/awaitable "monad-like" type.
async def checkout(id):
cart = await fetch_cart(id) # await == bind: unwrap the awaited value
receipt = await charge(cart) # next step depends on the previous result
return receipt.finalize()
# `await` flattens the awaitable so you never write Awaitable<Awaitable<X>>.
// Go has no Future monad; concurrency is channels + goroutines, composed explicitly.
ch := make(chan Result, 1)
go func() { ch <- charge(fetchCart(id)) }()
res := <-ch
// No flatMap over futures; you sequence with channels/sync primitives by hand.
| Monad | "Chaining means…" | Java bind | Python bind | Go bind |
|---|---|---|---|---|
| Maybe/Option | skip the rest if absent | Optional.flatMap | none (manual None checks / lib) | none (comma-ok) |
| Either/Result | skip the rest if errored | lib (Vavr Either) | none (exceptions / lib) | none (if err != nil) |
| List | for-each then concatenate | Stream.flatMap | nested comprehension | nested loop |
| Future/Promise | continue when it resolves | CompletableFuture.thenCompose | await | none (channels) |
State, Reader, Writer — a Gentle Intro¶
The four above wrap a value. The next three wrap a function — they thread something invisibly through a computation. You rarely build these by hand in Java/Python/Go, but recognizing them explains features you already use.
-
Reader — "every step can read a shared, read-only environment." The box is "a function from
Envto a value." Chaining threads the sameEnv(config, dependencies) through every step without passing it as an explicit argument each time. This is dependency injection, formalized. When a framework hands every handler the same request-scoped config, that's Reader in spirit. -
Writer — "every step can append to an accumulating log/output on the side." The box is "a value plus an accumulated extra (log, metrics)." Chaining concatenates the side-output. A structured logger that collects messages as a computation proceeds, or accumulating a sequence of audit events, is Writer-shaped.
-
State — "every step reads and updates a threaded state, without a mutable variable." The box is "a function from old state to (value, new state)." Chaining passes the new state to the next step automatically. A pseudo-random generator that returns
(value, nextSeed), or a parser that carries position, is State-shaped.
Reader: Env -> A "give me the environment, I'll produce A"
Writer: () -> (A, Log) "I produce A and a log to merge"
State: S -> (A, S') "give me state S, I'll produce A and new state S'"
The practical lesson: State/Reader/Writer turn an ambient concern (config, logging, threaded state) into a value you compose, instead of a side effect or a parameter on every function. In Java/Python/Go you usually achieve the same ends with DI containers, loggers, and explicit parameters — which is fine. Knowing the monadic framing just clarifies what those tools are doing. The deep version lives in Effect Tracking.
do-notation / for-comprehension: Flattening Sugar¶
Hand-written bind chains with lambdas get noisy fast — especially when a later step needs a value from an earlier one. Many languages add syntactic sugar that desugars to bind so the code reads like ordinary sequential statements while staying inside the box.
The mental model: each <- / await / for x in line is a bind; the boxing (absence, failure, async) is handled between the lines, not on every line.
-- Haskell do-notation (the canonical form)
checkout id = do
cart <- fetchCart id -- bind
valid <- validate cart -- bind, can short-circuit
receipt <- charge valid -- bind
return (finalize receipt) -- unit
desugars to:
fetchCart id >>= \cart ->
validate cart >>= \valid ->
charge valid >>= \receipt ->
return (finalize receipt)
How the three target languages compare:
- Python —
async/awaitis do-notation for awaitables; nestedforin a comprehension is do-notation for lists. There is no general do-notation (no<-forOptional/Result), which is why monadic error handling never caught on in mainstream Python — exceptions fill the role.
# `await` reads sequentially; the "awaitable" boxing is invisible between lines.
async def checkout(id):
cart = await fetch_cart(id)
valid = await validate(cart)
receipt = await charge(valid)
return receipt.finalize()
-
Java — no general do-notation either. Streams' chained
flatMap/mapandCompletableFuture'sthenComposeare the closest things, and they read as method chains rather than statements.Optional/Streamchains are the everyday "for-comprehension." -
Go — no do-notation and no plans for it; the explicit
if err != nil/comma-okis the sequencing, deliberately spelled out. Go's design philosophy treats invisible control flow as a cost, not a convenience.
Scala's
for { x <- a; y <- b } yield ...and Rust's?operator are the other famous examples:for-comprehension and?both desugar toflatMap/and_thenchains. None of Java/Python/Go offers the fully general version, which is the single biggest reason monadic style feels native in Scala/Haskell/Rust but bolted-on elsewhere.
Railway-Oriented Programming¶
Railway-oriented programming (Scott Wlaschin's term) is the practical, name-able pattern built on the Either/Result monad. Picture a railway with two parallel tracks: a success track and a failure track. Each step is a function that takes a success value and returns either a success or a failure.
bind(flatMap/and_then) is the switch: a step that runs on the success track and may divert you onto the failure track.- Once you're on the failure track, every subsequent step is skipped — the error is carried straight to the end.
mapis a step that can't fail: it stays on the success track and transforms the value.
The win: the happy path reads as a straight, top-to-bottom sequence; error handling is structural, not scattered. You don't write if (failed) return error; after every call — the railway does it for you (or, in Go, you write exactly that, by hand, every time).
// Java with Vavr Either — the happy path is the only path you read:
Either<Error, Receipt> checkout(String input) {
return parse(input) // Either<Error, Cart>
.flatMap(this::validate) // diverts to Err track on invalid
.flatMap(this::charge) // skipped if already on Err track
.map(Receipt::finalize); // success-only transform
}
// Go — the same railway, switches written explicitly. This is idiomatic Go,
// and many Go programmers consider the visibility a feature, not a burden.
func checkout(input string) (Receipt, error) {
cart, err := parse(input)
if err != nil { return Receipt{}, err }
if err := validate(cart); err != nil { return Receipt{}, err }
receipt, err := charge(cart)
if err != nil { return Receipt{}, err }
return finalize(receipt), nil
}
Both versions implement the same railway. The monadic version hides the switches; the Go version shows them. Choosing between them is a readability and team-convention decision, not a correctness one.
Key constraint: railway-oriented programming needs every step to return the same error type (or an error type the chain can merge). When steps fail in incompatible ways, you map their errors into a common type before chaining — otherwise the tracks don't connect.
Trade-offs: When Monadic Style Clarifies vs Obscures¶
Monadic chaining is a tool, not a virtue. At this level the skill is knowing when it earns its keep.
It clarifies when:
- A computation has many sequential steps that share one failure/absence/async concern. Threading that concern by hand (a
nullcheck orif errafter every line) is noise;flatMapfactors it out. - The language has good sugar for it (
awaitin Python,?in Rust,forin Scala). Sugar keeps the linear reading. - The boxing is already the idiom:
Optionalin a Java Streams pipeline,awaitin Python async code.
It obscures when:
- The language has no sugar and you end up with deeply nested lambdas. A four-deep
flatMap(x -> ...flatMap(y -> ...))in Java is often harder to read than four plain statements. - You mix monads (an
Optionalof aFutureof aList). Stacking boxes needs monad transformers, which are heavy machinery most mainstream code shouldn't carry. - The team isn't fluent. Code is read far more than written; an idiom half the team must decode is a net loss regardless of elegance.
- A single check would do.
findUser(id).flatMap(...)for one lookup is fine; reaching for aResultmonad to guard one division is ceremony.
The Go lesson, generalized: Go shows that explicit sequencing of failure and absence is a legitimate, often-preferable choice in a language without sugar. The monad is still there conceptually; Go just declines to hide it. Don't import monadic abstractions into a codebase whose language and team fight them — you'll get the cost (indirection, unfamiliar types) without the benefit (concise linear reads).
| Situation | Prefer |
|---|---|
| Many steps, one shared failure/async concern, good sugar | Monadic chaining |
| Go, or no sugar, control flow worth seeing | Explicit checks |
| One isolated check | A plain if |
| Stacked effects (Option of Future of List) | Rethink the design before reaching for transformers |
Common Mistakes¶
- Using
mapwhere you needbind. The tell is a nested box:Optional<Optional<X>>,List<List<X>>,Future<Future<X>>. The step returns a box, so it needsflatMap/thenCompose/and_then, notmap. - Treating
Optional/Resultas a fancynulland immediately calling.get()/.unwrap(). That throws away the whole point — the short-circuiting — and reintroduces the crash you were avoiding. Chain or pattern-match; unwrap only at the very edge. - Mixing error types in a railway. Steps that fail with different, unmergeable error types won't chain. Normalize errors into one type (or a sum type) first.
- Stacking monads casually. An
Optional<Future<Result<X>>>is three boxes deep and miserable to work with. This is the signal to flatten the design or use a dedicated combinator — not to nest threeflatMaps. - Forcing monads into a hostile language/team. Hand-rolling an
Eitherwith four-deep lambdas in Java, or aMaybetype in idiomatic Go, often produces code worse than plain checks. Match the style to the language. - Assuming
Promise/Futureobeys the laws perfectly. Eager futures start work at creation, so when you callunitmatters — they're monad-like. Fine in practice; just don't be surprised when a purist distinguishes them from lazyIO/Task. - Confusing
mapover aListwithflatMap.list.map(f)wherefreturns a list gives you a list of lists; you wantedflatMapto concatenate.
Test Yourself¶
- State
bind's signature in words, and explain why its result isM<B>and notM<M<B>>. - You have
Optional<User>and a methodfindManager(User) -> Optional<User>. Should you usemaporflatMap, and what goes wrong if you pick the other one? - Restate the three monad laws in one plain sentence each (no symbols).
- Translate this Python
awaitchain into the "what each line really is" framing:cart = await fetchCart(id); receipt = await charge(cart); return receipt. - Write the same three-step success-or-fail pipeline twice: once as an idiomatic Go
if err != nilchain, and once described as a railway withflatMap. What's identical, what differs? - Give one situation where monadic chaining clarifies code and one where it obscures it. What property of the language tips the balance?
Cheat Sheet¶
| Concept | One-liner |
|---|---|
| Monad | A box type with unit (wrap a value) and bind (chain a box-returning step, flattening one layer) + three laws |
unit (of/return/pure) | Put a plain value in the box; the way into a chain |
bind (flatMap/then/and_then) | M<A> × (A -> M<B>) -> M<B>; chain without nesting boxes |
map vs bind | map for plain-returning steps; bind for box-returning steps |
| Left identity | unit adds no behavior |
| Right identity | a unit step is a no-op |
| Associativity | grouping of chained steps doesn't matter → linear reading & refactor |
| Maybe/Option | chain skips the rest if absent |
| Either/Result | chain skips the rest if errored, carrying the reason |
| List | chain = map-each-then-concatenate (nondeterminism) |
| Future/Promise | chain = continue when it resolves (monad-like, eager) |
| State / Reader / Writer | thread state / read-only env / accumulating log through steps |
| do-notation | sugar that makes each <-/await/for line a bind |
| Railway-oriented | Result chain: happy path straight, errors structural |
Language support at a glance:
Option bind | Result bind | List bind | Future bind | General do-notation | |
|---|---|---|---|---|---|
| Java | Optional.flatMap | lib only | Stream.flatMap | thenCompose | no |
| Python | no (None/lib) | no (exceptions/lib) | nested comp. | await | no |
| Go | no (comma-ok) | no (if err) | nested loop | no (channels) | no |
Summary¶
- A monad is a small interface:
unit(wrap a plain value) andbind(chain a step that itself returns a box, flattening one layer) — plus three laws.Optional,Result,List, andPromiseare all this one shape. - The defining behavior is flattening:
bindturnsA -> M<B>chained ontoM<A>intoM<B>, neverM<M<B>>. Reach forbind/flatMap(notmap) whenever a step returns its own box. - The three laws — left identity, right identity, associativity — say unit is a true do-nothing wrapper and chaining is group-order-agnostic. That's what lets monadic code read and refactor like a linear script.
- The common monads differ only in what "chaining" means: skip-if-absent (Maybe), skip-if-errored (Either/Result), for-each-and-concat (List), continue-when-resolved (Future). State/Reader/Writer thread state/config/logs through a computation.
- do-notation / for-comprehension /
await/?are sugar that desugars tobind, keeping the linear reading. Among our three languages only Python'sawait(and list comprehensions) qualify; Java and Go have no general form. - Java has real
flatMapforOptional/Stream/CompletableFuturebut no sugar; Python uses exceptions andNonefor most of this and offers monads only via libraries; Go deliberately rejects implicit chaining in favor of explicitif err != nil/ comma-ok — the railway written by hand. - Use monadic style when many steps share one effect and the language has sugar; avoid it when it produces nested lambdas, stacked boxes, or fights the language/team. Next: Effect Tracking takes the idea to its conclusion — modeling side effects themselves as values.
Further Reading¶
- Functional Programming in Scala — Chiusano & Bjarnason ("the red book") — Chapter 11 builds the monad interface and the laws from first principles.
- Railway Oriented Programming — Scott Wlaschin (fsharpforfunandprofit.com) — the canonical, illustrated explanation of Result-chaining.
- Domain Modeling Made Functional — Scott Wlaschin (2018) — railway-oriented programming applied to real domain code.
- "Monads for functional programming" — Philip Wadler (1992) — the paper that introduced State/Reader/Writer framings to a wide audience (readable, despite the title).
- The official
java.util.Optionalandjava.util.stream.StreamJavadocs — the most-used monad-shaped APIs you already have.
Related Topics¶
- Algebraic Data Types —
Option/Eitherare sum types; this is how monads are implemented underneath. - Map / Filter / Reduce —
mapis the gateway toflatMap/bind. - Composition —
bindis what lets you compose functions that return wrapped values. - Effect Tracking — the
IO/Taskmonad and the pure-core / impure-shell pattern. - Currying & Partial Application — the function shapes that show up inside
bindand in Reader/State. - Error Handling Patterns — exceptions vs result-values, the mainstream counterpart to the Either monad.
In this topic
- junior
- middle
- senior
- professional