Technology  /  Scala

๐Ÿ”ด Scala 21 guides ยท updated 2026

The JVM's hybrid OOP/functional language โ€” immutability, pattern matching, and the type system that powers Spark and Akka, from first principles to production.

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.

desugars to

desugars to

desugars to

for (x <- xs) yield f(x)

xs.map(x => f(x))

for (x <- xs; y <- ys) yield (x,y)

xs.flatMap(x => ys.map(y => (x,y)))

for (x <- xs if p(x)) yield x

xs.withFilter(p).map(x => x)


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 generator
val 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 together
val 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 == y

for-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 Either
def 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 Option

Binding 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 consistently
for { x <- List(1,2,3); y <- List(10,20) } yield x + y // List
for { x <- Some(5); y <- Some(10) } yield x + y // Option
for { x <- Right(5); y <- Right(10) } yield x + y // Either (as Either[Nothing, Int])
for { x <- Try(5); y <- Try(10) } yield x + y // Try

This 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 encountered

This 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

SymptomLikely cause
โ€type mismatchโ€ error mixing Option and Either generators in one comprehensionEvery 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 collectionwithFilter 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 expectedDouble 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 errorRemember 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.