Skip to content

09 · For-Comprehensions

You've used for x <- list do ... for a plain loop since Level 1, and Module 4 used for-comprehensions to chain Option/Either validations without spelling out flatMap by hand. This module explains exactly what a for-comprehension desugars into — because once you know that, "why doesn't this compile" and "why did this run twice" stop being mysteries.

It's not a loop — it's sugar for map/flatMap

A for-comprehension with a yield is rewritten by the compiler into calls to map and flatMap (and withFilter, covered below) on whatever type you're iterating over. This one:

def half(n: Int): Option[Int] =
  if n % 2 == 0 then Some(n / 2) else None

def parseInt(s: String): Option[Int] = s.toIntOption

val result =
  for
    a <- parseInt("20")
    b <- half(a)
    c <- half(b)
  yield a + b + c

println(result)   // Some(35)  -- 20 + 10 + 5

is exactly equivalent to:

val desugared =
  parseInt("20").flatMap(a => half(a).flatMap(b => half(b).map(c => a + b + c)))

println(desugared)          // Some(35)
println(result == desugared) // true

Every generator (<-) except the last one becomes a flatMap; the last one becomes a map (since it's the one that produces the final yielded value, and only the outermost result needs wrapping). This is why a for-comprehension works on Option, Either, List, Future, and any other type you define yourself — as long as it has map and flatMap with compatible signatures, for syntax works on it, no special support needed.

Short-circuiting falls out of flatMap, not special for logic

Because the desugaring is just nested flatMap calls, "stop at the first None/Left" isn't a feature of for — it's simply how Option.flatMap and Either.flatMap already behave (a flatMap on None/Left returns None/Left without ever calling the function you passed):

val result2 =
  for
    a <- parseInt("7")   // Some(7) -- parses fine
    b <- half(a)         // 7 is odd -> None
    c <- half(b)         // never runs -- flatMap on None short-circuits
  yield a + b + c

println(result2)   // None
def validatePositive(n: Int): Either[String, Int] =
  if n > 0 then Right(n) else Left(s"$n is not positive")

val eitherResult =
  for
    x <- validatePositive(5)
    y <- validatePositive(3)
  yield x * y
println(eitherResult)   // Right(15)

val eitherFail =
  for
    x <- validatePositive(5)
    y <- validatePositive(-3)
  yield x * y
println(eitherFail)   // Left(-3 is not positive)

for over collections: it's a cartesian product, not nested loops in disguise

Applied to two Lists, the same flatMap/map desugaring produces every combination of elements — worth seeing explicitly, since it's easy to picture for over two lists as "zip them together" when it actually produces their full cross product:

val pairs =
  for
    x <- List(1, 2, 3)
    y <- List("a", "b")
  yield (x, y)

println(pairs)
// List((1,a), (1,b), (2,a), (2,b), (3,a), (3,b))

Guards: the if inside a for desugars to withFilter

An if condition inside a for-comprehension (not the block after yield) becomes a call to withFilter (a lazy relative of filter used specifically to support for) before the next map/flatMap in the chain:

val evensDoubled =
  for
    x <- List(1, 2, 3, 4, 5, 6)
    if x % 2 == 0
  yield x * 2

println(evensDoubled)   // List(4, 8, 12)

for without yield: pure side effects

Drop yield entirely and the comprehension desugars to foreach instead of map/flatMap — it runs for its side effects and produces Unit, not a new collection:

for
  x <- List(1, 2, 3)
do println(s"side effect: $x")
// side effect: 1
// side effect: 2
// side effect: 3

This is exactly the form you've been using since Level 1's for x <- xs do ... loops — now you know it's foreach under the hood, not special loop syntax.

The trap: mixing incompatible container types in one for

Because for desugars to flatMap/map calls on the specific type of each generator, every generator in one comprehension has to be a type whose flatMap can plausibly chain with the others — in practice, this almost always means "all generators must be the same kind of container" (all Option, or all List, or all Future, etc.):

// This will NOT compile:
// for
//   a <- Some(1)
//   b <- List(1, 2)
// yield (a, b)
//
// Option's flatMap expects a function returning Option[_]; List(1, 2) isn't
// an Option, so there's no way to desugar this into a type-correct
// flatMap call. The fix is almost always: convert one side to match the
// other explicitly (e.g. a.toList) rather than mixing container types.

If you ever see a for-comprehension refuse to compile with a confusing type-mismatch error, the first thing to check is whether every generator really is the same kind of container — the error message rarely says that directly.

How It Actually Works

The compiler literally rewrites your for block before it ever reaches type-checking: each x <- xs becomes a flatMap (except the last generator, which becomes map), each if guard becomes a withFilter call, and yield supplies the function body — so a for over Option, List, or Future all "just work" because each of those types independently implements map/flatMap/withFilter with its own semantics (an Option's flatMap short-circuits on None, a Future's runs the next step on a different thread once the prior one completes). Short-circuiting isn't special-cased into for at all — it falls straight out of how each container's own flatMap decides whether to call the function you pass it.

Cheat sheet

for syntax Desugars to
for x <- xs yield f(x) (last/only generator) xs.map(f)
for x <- xs; y <- ys yield ... xs.flatMap(x => ys.map(y => ...))
for x <- xs if cond yield f(x) xs.withFilter(cond).map(f)
for x <- xs do sideEffect(x) (no yield) xs.foreach(sideEffect)

Exercise

Write def safeDivide(a: Int, b: Int): Option[Int] = if b == 0 then None else Some(a / b). Using a for-comprehension over three calls to safeDivide, compute ((100 / a) / b) / c for some a, b, c of your choosing, short-circuiting to None if any denominator is zero. Then manually rewrite your for-comprehension as nested flatMap/map calls (no for syntax at all) and confirm — by comparing the two results with == — that they produce identical output for at least one input that succeeds and one that hits a None partway through.