all_lessons/functional programming/10 · Monads & forlesson 11 / 15

Monads, flatMap, and for-comprehensions

Lesson 08 gave us map: transform a value inside a context (an Option, a List) without leaving the context. Lesson 09 drilled the discipline that combining operations must be lawful so refactorings stay safe. This lesson hits the wall map cannot climb: what happens when the function you map with itself returns a context. The answer — flatMap — is the entire content of the word "monad," and it is what lets you sequence steps that each produce a new context. for-comprehensions are just readable sugar over it.

Book source
This maps to the book's treatment of monads as the third member of the functor → monoid → monad escalation: the structure that adds sequencing of context-producing computations on top of the functor's map. We keep the book's stance — a monad is an ordinary pattern with a precise signature and three laws, not a mystery — but the chain examples, traces, and the desugaring walk-through are original. The aim is to make "monad" mean exactly one thing to you: a type with a lawful flatMap.
Linear position
Prerequisite: lesson 08 (a functor is F[_] with a lawful map: F[A] => (A => B) => F[B]) and lesson 09 (lawful combining; associativity makes regrouping safe). You should also be comfortable with Option and List from lesson 02.
New capability: recognise the nested-context problem map cannot solve, fix it with flatMap, define a monad as functor + unit + flatMap obeying three laws, and read any for-comprehension as mechanical sugar over flatMap/map.
The plan
Six moves. (1) Hit the wall: map with a context-returning function produces a nested context, and chaining gives an unusable tower. (2) Introduce flatten and define flatMap = map then flatten; redo the chain so it stays one level deep and short-circuits. (3) Define a monad: functor + unit + flatMap. (4) State the three monad laws and a concrete check. (5) Read for-comprehensions as exact sugar over flatMap/map/withFilter. (6) See the unifying point: many contexts share one sequencing interface — which is why the pattern earns a name.

1 · The wall: map produces a nested context

Recall the functor contract from lesson 08: map takes an F[A] and a function A => B and gives back an F[B] — the context stays, the inside changes. That works perfectly as long as the function you supply returns a plain value. The trouble starts when the natural function for the job returns a context of its own.

Take three lookups, each of which can fail, so each returns an Option — a context meaning "maybe absent" (lesson 08's running example):

def findUser(id: Int): Option[User]          // user might not exist
def findAccount(u: User): Option[Account]    // user might have no account
def findBalance(a: Account): Option[Money]   // account might be unfunded

Now try to go from an id to a balance using only map. The function we feed to map is findAccount, whose return type is Option[Account] — itself a context. So map faithfully wraps that result in the outer Option:

val u: Option[User]              = findUser(id)
val a: Option[Option[Account]]   = u.map(findAccount)      // map of (User => Option[Account])
val b: Option[Option[Option[Money]]] = a.map(_.map(findBalance))  // and it gets worse

Each step adds a layer. After three context-returning steps you are holding an Option[Option[Option[Money]]] — a value buried under a tower of contexts, where "is it actually present?" now means "peel three layers and check each." map is doing exactly its job (preserve the context, transform the inside); the inside just happened to be another context, so the towers stack. map cannot flatten — that is not its job — so it can never get you back to a single Option[Money]. That is the wall.

2 · flatten and flatMap collapse the tower

The missing piece is an operation that removes one redundant layer — turns an F[F[A]] back into an F[A]:

flatten : F[F[A]] => F[A]

For Option, flatten says: Some(Some(x)) becomes Some(x); Some(None) and None both become None. For List, flatten concatenates a list of lists into one list. It is the inverse move to the nesting that map introduced.

The pattern "map with a context-returning function, then flatten the result" is so common it gets one name and one method, flatMap:

flatMap(f)  =  map(f)  then  flatten

Its signature is the whole point — burn it in, because it is the definition of "monad":

flatMap : F[A] => (A => F[B]) => F[B]

Read it aloud: given a context-carrying F[A] and a function that takes a plain A and produces a new context F[B], give back a single F[B] — no nesting. That is exactly the shape our lookups have. Redo the chain with flatMap and it stays one level deep the whole way:

val balance: Option[Money] =
  findUser(id)            // Option[User]
    .flatMap(findAccount) //   User    => Option[Account]  ⇒ stays Option[Account]
    .flatMap(findBalance) //   Account => Option[Money]    ⇒ stays Option[Money]

Two bonuses fall out for free. First, no tower: the result is a plain Option[Money]. Second — and this is what makes Option's flatMap so useful — it short-circuits: the moment any step yields None, the rest of the chain is skipped and the whole expression is None. You wrote the happy path; the failure path threads itself.

3 · What a monad actually is

You now have everything. A monad is a functor (lesson 08) that additionally provides two operations:

unit (a.k.a. pure)
unit : A => F[A] — lift a plain value into the context in the most trivial way. For Option: a => Some(a). For List: a => List(a). It is how a step produces a context from an ordinary result.
flatMap
flatMap : F[A] => (A => F[B]) => F[B] — sequence a step whose result is itself a context, without nesting. This is the whole new capability over a plain functor.

In Scala you can sketch the abstraction as a trait extending the functor from lesson 08:

trait Functor[F[_]] {
  def map[A, B](fa: F[A])(f: A => B): F[B]
}

trait Monad[F[_]] extends Functor[F] {
  def unit[A](a: A): F[A]                              // pure
  def flatMap[A, B](fa: F[A])(f: A => F[B]): F[B]      // sequence

  // map comes for free, proving Monad really IS a Functor:
  def map[A, B](fa: F[A])(f: A => B): F[B] =
    flatMap(fa)(a => unit(f(a)))
}

So the one-sentence definition to carry away: a monad is a functor plus a lawful way to sequence steps that each produce a new context. There is no box, no burrito — there is a type F[_] and a flatMap with that signature. Option, List, and (next lesson) Try and Either are all monads in exactly this sense.

4 · The three monad laws, and why they matter

Having the right signature is not enough — lesson 07 taught us that lawful composition is what makes patterns trustworthy. A monad's unit and flatMap must obey three laws:

LawStatementIn words
Left identityunit(a).flatMap(f) == f(a)wrapping then sequencing is the same as just calling funit adds no behaviour.
Right identitym.flatMap(unit) == msequencing with the trivial step changes nothing.
Associativitym.flatMap(f).flatMap(g) == m.flatMap(x => f(x).flatMap(g))how you group a sequence of steps does not change the result — exactly lesson 09's associativity, now for steps.

Why care? Because these laws are what make a for-comprehension behave predictably and refactorings safe. Associativity, in particular, says you can extract a middle run of steps into a helper, or re-bracket them, without changing meaning — the same equational-reasoning power lesson 09 gave fold, now applied to control flow. It is the principled replacement for the brittle, deeply nested callbacks of lesson 07: there, re-ordering or factoring callbacks risked subtle bugs; here the law guarantees the rewrite is sound.

Worked example — trace the pipeline, both forms, with a failure
Suppose findUser(7) = Some(u), findAccount(u) = None (the user has no account), and findBalance is never reached. flatMap form: Some(u).flatMap(findAccount)findAccount(u)None; then None.flatMap(findBalance)None (flatMap on None ignores its function). Result: None, and findBalance was never called — the short-circuit. Now check left identity on the first step: unit(u).flatMap(findAccount) is Some(u).flatMap(findAccount), which the law says equals findAccount(u) — and indeed both are None. The law holds, so the next section's sugar is guaranteed to mean the same thing.

5 · for-comprehensions are sugar over flatMap/map

Chained flatMap reads backwards once you have more than two steps. Scala lets you write the identical computation as a for-comprehension:

// the chained flatMap form (the trailing .map(b => b) is identity — the final `yield`) …
findUser(id).flatMap(u => findAccount(u).flatMap(a => findBalance(a).map(b => b)))

// … is EXACTLY this for-comprehension:
for {
  u <- findUser(id)      // each `<-` is a flatMap
  a <- findAccount(u)
  b <- findBalance(a)
} yield b                // the final `yield` is a map

This is not a new feature — it is pure syntax. The compiler desugars it by a fixed mechanical rule:

every x <- e (except the last)
becomes e.flatMap(x => …rest…)
the final yield r
becomes .map(x => r) on the last generator
an if cond guard
becomes .withFilter(x => cond)

So the for above desugars step-by-step to the nested flatMap/map beside it. Because Option is a lawful monad, all the §2 properties carry over verbatim: one level deep, and short-circuiting — if findAccount(u) is None, the comprehension yields None and never evaluates the rest. The for form is what you write in real code; the desugaring is what you must be able to recite in an interview.

The same sugar over a different monad does something that looks completely different — because the meaning is entirely the monad's flatMap. List's flatMap runs the body once per element and concatenates, so a for over two lists is a cartesian product:

for { x <- List(1, 2); y <- List("a", "b") } yield (x, y)
// = List(1,2).flatMap(x => List("a","b").map(y => (x, y)))
// = List((1,"a"), (1,"b"), (2,"a"), (2,"b"))     // every pair, not 2 iterations

6 · The unifying point: one interface, many contexts

Step back. "Optionality" (Option), "nondeterminism / many results" (List), "typed error" (Either, lesson 11), "a value arriving later" (Future), "an effect to be run" (IO, lesson 14) — these feel like unrelated problems. Yet each is a context F[_] for which "do the next context-producing step" is captured by the same flatMap signature and the same three laws. That is the entire payoff of naming "monad": once a type is a lawful monad, you already know how to sequence it, you can write it in a for-comprehension, and your equational reasoning transfers unchanged. You learn the interface once and reuse it across every effect you will meet for the rest of the series.

Common mistakes / failure modes

"A monad is a box / a burrito"
Hand-waving that explains nothing and misleads. Insist on the signature: a monad is a type with unit: A => F[A] and flatMap: F[A] => (A => F[B]) => F[B] obeying three laws. If you can write a lawful flatMap, you have a monad; if you cannot, no metaphor helps.
Thinking a for-comprehension is a loop
It is sugar for flatMap/map. For Option there is no iteration at all (zero or one element); for List the "looping" is really nested flatMap producing a product. The behaviour is whatever the monad's flatMap does.
Reaching for map when you need flatMap
If the function you are mapping returns an F[...], map gives you a nested F[F[...]] (§1). The type error / extra layer is the signal to switch to flatMap.
Mixing two monads in one for-comprehension
for { u <- findUser(id); xs <- List(1,2) } yield … does not compile: every <- must be over the same F[_]. You cannot flatMap an Option with a function returning a List. Combining different effects needs extra machinery (monad transformers), out of scope here.

Checkpoint exercise

Try it
(a) Translate both ways. Take this nested form and rewrite it as a for-comprehension: parseInt(s).flatMap(n => safeSqrt(n).flatMap(r => reciprocal(r))) (each function returns an Option). Then take the for-comprehension for { a <- step1; b <- step2(a); c <- step3(b) } yield c and write its exact flatMap/map desugaring. (b) Predict the result. Evaluate for { x <- List(1, 2, 3); y <- List(10, 20) } yield x * y by hand — how many elements, and which? (Answer: six, the cartesian product: List(10, 20, 20, 40, 30, 60).) Confirm it equals List(1,2,3).flatMap(x => List(10,20).map(y => x*y)).

Where this points next

We now know what flatMap and for buy you, in the abstract. Lesson 11 cashes that out into the three monads you will actually use every day: Option for plain absence, Either for typed errors that carry a reason, and Try for capturing thrown exceptions as values. Because all three are lawful monads, everything from this lesson — short-circuiting, for-comprehension sugar, safe refactoring — applies immediately; lesson 11 is mostly "which one when," and how this replaces null and exceptions with typed failure that composes.

Takeaway
map transforms inside a context but cannot remove a layer, so mapping with a context-returning function builds an unusable tower of F[F[F[A]]]. flatMap fixes this — flatMap = map then flatten, with signature F[A] => (A => F[B]) => F[B] — sequencing context-producing steps one level deep and (for Option/Either) short-circuiting on the first failure. A monad is exactly a functor plus unit: A => F[A] and that flatMap, obeying left identity, right identity, and associativity — the laws that make refactoring and for-comprehensions safe. A for-comprehension is mechanical sugar: each <- is a flatMap, the final yield a map, an if a withFilter — which is why the same syntax short-circuits over Option yet yields a cartesian product over List. One interface, many contexts: that is why "monad" is worth naming.

Interview prompts