For-Comprehensions in Scala Explained: Desugaring to map, flatMap and withFilter
A for comprehension in Scala looks like a loop borrowed from Python or Java โ and that resemblance is exactly what misleads people learning the language. It isnโt a loop construct at all; itโs syntax sugar the compiler mechanically rewrites into a chain of map, flatMap, and withFilter calls. Once you can do that rewrite in your head, for comprehensions stop being a special case to memorize and become simply a more readable way to write chains you already know from the collections and Option/Either guides.
The Simplest Case โ Iterating for Side Effects
for (i <- 1 to 5) { println(i)}// Desugars to:(1 to 5).foreach(i => println(i))Without a yield, a for loop is purely for side effects and produces Unit โ it desugars to a single .foreach call. This is the case that most resembles an imperative loop from other languages, and itโs the only case where โloopโ is actually an accurate mental model.
forโฆyield โ Transformation, Not Iteration
val doubled = for (x <- List(1, 2, 3)) yield x * 2// Desugars to:val doubled = List(1, 2, 3).map(x => x * 2)The moment yield appears, the for comprehension produces a new collection built from transforming each element โ and it desugars to exactly the map call youโd write by hand. This is the first concrete evidence that for...yield isnโt looping at all; itโs map, spelled differently.
Multiple Generators โ Where flatMap Enters
val pairs = for { x <- List(1, 2) y <- List("a", "b")} yield (x, y)
// List((1,"a"), (1,"b"), (2,"a"), (2,"b"))// Desugars to:val pairs = List(1, 2).flatMap(x => List("a", "b").map(y => (x, y)))With more than one generator (x <- ... and y <- ...), the desugaring becomes a nested structure: every generator except the last becomes a flatMap, and only the final one becomes a map. This is exactly the pattern needed to produce a flat combined result instead of a nested collection of collections โ recall from the collection operations guide that flatMap exists precisely to avoid ending up with List[List[(Int,String)]] when you actually want List[(Int,String)].
// Three generators โ the nesting goes one level deeper for each additional generatorval triples = for { x <- List(1, 2) y <- List(10, 20) z <- List(100, 200)} yield x + y + z
// Desugars to:val triples2 = List(1, 2).flatMap(x => List(10, 20).flatMap(y => List(100, 200).map(z => x + y + z) ))This is precisely why for comprehensions become genuinely valuable once you have more than one or two generators: writing the equivalent nested flatMap/map chain by hand gets visually harder to follow with every additional level, while the for syntax stays flat and readable regardless of how many generators are involved.
Guards โ if Inside a for Comprehension
val result = for { x <- List(1, 2, 3, 4, 5) if x % 2 == 0} yield x * 10
// List(20, 40)// Desugars to (roughly):val result2 = List(1, 2, 3, 4, 5).withFilter(x => x % 2 == 0).map(x => x * 10)An if inside a for comprehension filters the generatorโs values before they reach subsequent steps โ it desugars to withFilter specifically (not plain filter), which is a lazier variant that avoids building an intermediate filtered collection before the subsequent map/flatMap runs, an optimization detail worth knowing but rarely one that changes your codeโs actual logic.
// Multiple generators AND a guard togetherval validPairs = for { x <- 1 to 3 y <- 1 to 3 if x != y} yield (x, y)
// Vector((1,2), (1,3), (2,1), (2,3), (3,1), (3,2)) โ excludes pairs where x == yfor-Comprehensions Over Option โ The Real Payoff
def findUser(id: String): Option[String] = Map("1" -> "Alice").get(id)def findEmail(name: String): Option[String] = Map("Alice" -> "alice@example.com").get(name)def findPhone(name: String): Option[String] = Map("Alice" -> "555-1234").get(name)
val contactInfo = for { name <- findUser("1") email <- findEmail(name) phone <- findPhone(name)} yield s"$name: $email, $phone"
// Some("Alice: alice@example.com, 555-1234")// Desugars to exactly the nested flatMap chain from the Option/Either guide:val contactInfo2 = findUser("1").flatMap(name => findEmail(name).flatMap(email => findPhone(name).map(phone => s"$name: $email, $phone") ))This is where for comprehensions earn their keep in real, everyday Scala code: chaining three Option-returning lookups by hand (as the Option/Either/Try guide did with explicit .flatMap calls) becomes visually noisy past two or three steps, with increasing nesting. The for syntax stays flat regardless of chain length, and if any step returns None, the entire comprehension short-circuits to None automatically โ identical short-circuiting behavior to the manual flatMap chain, just far more readable once there are more than two steps involved.
// The same pattern works identically for Eitherdef parseAge(s: String): Either[String, Int] = s.toIntOption.toRight(s"'$s' is not a number")def validateAge(age: Int): Either[String, Int] = if (age >= 0) Right(age) else Left("Age cannot be negative")
val result = for { age <- parseAge("30") validated <- validateAge(age)} yield validated
// Right(30) โ or Left(errorMessage) if either step failed, short-circuiting exactly like OptionBinding Intermediate Values Without a Generator
val result = for { x <- List(1, 2, 3) squared = x * x // an intermediate binding, not a generator โ no <- here if squared > 3} yield squared
// List(4, 9)A line using = instead of <- inside a for comprehension binds an intermediate value (computed from earlier generators) without itself being a generator to iterate over โ useful for avoiding repeated computation of the same expression across multiple later lines, and it desugars to an extra nested map step wrapping the rest of the comprehension.
Why for Comprehensions Work Identically Across Option, List, Either, and Try
// The SAME syntax works across every one of these types, because they all define map/flatMap consistentlyfor { x <- List(1,2,3); y <- List(10,20) } yield x + y // Listfor { x <- Some(5); y <- Some(10) } yield x + y // Optionfor { x <- Right(5); y <- Right(10) } yield x + y // Either (as Either[Nothing, Int])for { x <- Try(5); y <- Try(10) } yield x + y // TryThis uniformity isnโt a coincidence โ every type that defines map and flatMap with compatible signatures can be used inside a for comprehension, regardless of what that type actually represents (a collection of many values, an optional single value, a success/failure result). This is Scalaโs practical, hands-on introduction to the more formal concept of a โmonadโ from functional programming theory โ you donโt need the formal definition to use the pattern productively, but recognizing that List, Option, Either, and Try all share this same map/flatMap shape is what makes the identical for syntax work across all of them.
A Realistic End-to-End Example
case class Address(street: String, city: String)case class Customer(name: String, address: Address, email: String)
def parseName(input: Map[String, String]): Either[String, String] = input.get("name").toRight("Missing name")
def parseAddress(input: Map[String, String]): Either[String, Address] = for { street <- input.get("street").toRight("Missing street") city <- input.get("city").toRight("Missing city") } yield Address(street, city)
def parseEmail(input: Map[String, String]): Either[String, String] = input.get("email").filter(_.contains("@")).toRight("Missing or invalid email")
def parseCustomer(input: Map[String, String]): Either[String, Customer] = for { name <- parseName(input) address <- parseAddress(input) email <- parseEmail(input) } yield Customer(name, address, email)
val validInput = Map("name" -> "Alice", "street" -> "1 Main St", "city" -> "Boston", "email" -> "alice@example.com")val invalidInput = Map("name" -> "Alice", "street" -> "1 Main St")
parseCustomer(validInput) // Right(Customer(Alice, Address(1 Main St, Boston), alice@example.com))parseCustomer(invalidInput) // Left("Missing city") โ stops at the first failure encounteredThis is the standard shape of real-world validation logic composed from smaller, independently-testable pieces โ each parseX function is pure and pattern-matches naturally into a for comprehension, and the whole pipeline short-circuits cleanly on the first failure with a specific, useful error message. Note that this stops at the first failure rather than collecting every validation error at once โ for accumulating multiple errors simultaneously (telling a user about all four missing fields at once, not just the first), a different, more advanced tool (Eitherโs cousin from libraries like Cats, called Validated) is the right fit, beyond this guideโs scope.
Common Pitfalls
| Symptom | Likely cause |
|---|---|
โtype mismatchโ error mixing Option and Either generators in one comprehension | Every generator in a single for comprehension must be the same โshapeโ (all Option, all Either, all List) โ convert one to match the other first (.toRight(...) for OptionโEither, for instance) |
A for over List with a guard runs surprisingly slowly on a huge collection | withFilter avoids one allocation but the underlying iteration cost is unchanged โ for genuinely large collections, consider restructuring to filter earlier or use a more targeted data structure |
Comprehension over Either doesnโt short-circuit as expected | Double check the Left/Right type parameters line up across every generator โ a type mismatch here can silently produce a different Either shape than intended |
An intermediate = binding causes a confusing type error | Remember itโs still nested inside the desugared map chain โ a mistake in an earlier generatorโs type propagates forward into every subsequent binding |
Frequently Asked Questions
Is a for comprehension ever actually faster or slower than the equivalent map/flatMap chain? No โ since it desugars directly to that chain, the compiled output (and therefore performance) is identical. The choice between the two is purely about readability, not performance.
Why does a guard (if) inside a for comprehension desugar to withFilter instead of filter? withFilter is a lazier variant that doesnโt build an intermediate filtered collection before the following map/flatMap executes โ for collections, this avoids allocating a throwaway filtered copy just to immediately transform it further, an optimization the desugaring applies automatically without you needing to think about it.
When should I write an explicit .flatMap(...).flatMap(...) chain instead of a for comprehension? For a single step or two, an explicit chain is often just as readable and slightly more direct. Past two or three sequential steps, for comprehensions become noticeably more readable, since the nesting that flatMap chains accumulate is exactly what for syntax flattens back out.
Can I use pattern matching directly inside a for comprehensionโs generator? Yes โ for { (key, value) <- someMap } yield ... destructures each tuple as itโs generated, exactly like the pattern-matching lambda shorthand from the pattern matching guide.
Do I need to understand the formal โmonadโ concept from functional programming to use for comprehensions well? No โ recognizing that any type providing map and flatMap (Option, Either, Try, List, and others) can be used inside a for comprehension identically is enough for nearly all practical purposes. The formal theory is worth learning eventually if you go deeper into functional programming, but itโs not a prerequisite for using this pattern correctly day to day.
Whatโs Next
For-comprehensions are readable sugar over map/flatMap/withFilter โ not a separate language feature, but a lens that makes chains across List, Option, Either, and Try all look the same. This closes out the functional programming core of this series. Next: Generics and Variance in Scala, covering the type-system machinery โ covariance, contravariance, and bounds โ that lets generic code like List[T] behave correctly across subtype relationships.