Scala/Play, Part 7: List For-Comprehensions - From Guards to flatMap
For-comprehensions were the first Scala feature that made me stop and say: this is neat. They helped me read dependent Option lookups without getting lost between map and flatMap. After learning List.map, filter and find, I wanted to understand the same syntax over several values.
This lesson starts with one generator, adds a guard, then combines two lists. Every example uses Scala 2.13.18 and the fictional policies from my learning lab. No notifications are sent; the final strings are labels.

One generator: map in familiar syntax
Our data is deliberately small. The fields are the policy number, base premium and claim-free years:
case class PolicySnapshot(
number: String,
basePremium: Int,
claimFreeYears: Int
)
val policies: List[PolicySnapshot] = List(
PolicySnapshot("POL-001", 600, 3),
PolicySnapshot("POL-002", 600, 2),
PolicySnapshot("POL-003", 500, 3)
)
val numbers: List[String] = for {
policy <- policies
} yield policy.number
I read this as: for each policy, produce its number. The generator binds policy to one PolicySnapshot at a time. The yield expression produces a String, and the whole comprehension produces List[String].
// Equivalent method call:
policies.map(policy => policy.number)
// Result:
List("POL-001", "POL-002", "POL-003")
There is no manual result buffer. With this List, map constructs the resulting list in input order. An empty policies list produces an empty result.
A guard decides which values reach yield
val experiencedNumbers: List[String] = for {
policy <- policies
if policy.claimFreeYears >= 3
} yield policy.number
// List("POL-001", "POL-003")
The if line is a guard. POL-001 passes, POL-002 fails, and POL-003 passes. There is no else because a rejected policy contributes no result. Raising the threshold to ten gives List(). This is an exercise predicate, not an insurance business rule.
The method translation deserves care:
policies
.withFilter(policy => policy.claimFreeYears >= 3)
.map(policy => policy.number)
withFilter defers selection until map consumes it, avoiding an intermediate filtered list. Our earlier filter(...).map(...) gives the same values with these pure callbacks, but builds that intermediate list. I keep the distinction visible rather than claiming the compiler literally inserts filter.

Two generators: every policy, every channel
Now I add two channel names:
val channels: List[String] = List("email", "sms")
val notifications: List[String] = for {
policy <- policies
channel <- channels
} yield policy.number + " via " + channel
For each policy, the second generator visits both channels. Both names are available in yield. Three policies and two channels produce six labels:
List(
"POL-001 via email", "POL-001 via sms",
"POL-002 via email", "POL-002 via sms",
"POL-003 via email", "POL-003 via sms"
)
This is every combination of these two independent lists, not positional pairing. The order follows the generators: finish the channels for POL-001, then continue with POL-002.
For equivalent Java objects with a number() accessor, I would recognize the traversal as nested loops:
var notifications = new ArrayList<String>();
for (var policy : policies) {
for (var channel : channels) {
notifications.add(policy.number() + " via " + channel);
}
}
The Java example makes accumulation explicit. The Scala comprehension expresses the produced values through yield.
Why the outer call is flatMap
val notificationsWithMethods: List[String] =
policies.flatMap { policy =>
channels.map { channel =>
policy.number + " via " + channel
}
}
The inner map builds a List[String] for one policy. The outer function therefore returns a list, not a single label. That return type is the reason flatMap belongs here.
If I use map on the outside too, the result keeps the groups:
val grouped: List[List[String]] = policies.map { policy =>
channels.map { channel =>
policy.number + " via " + channel
}
}
// List(
// List("POL-001 via email", "POL-001 via sms"),
// List("POL-002 via email", "POL-002 via sms"),
// List("POL-003 via email", "POL-003 via sms")
// )
flatMap concatenates those per-policy results into one List[String]. It removes one layer of collection structure. It does not sort the values or remove duplicates.

An empty inner list contributes nothing
val noNotifications: List[String] = for {
policy <- policies
channel <- List.empty[String]
} yield policy.number + " via " + channel
// List()
Each policy has zero channel combinations. The inner map returns an empty list each time, and combining those lists still gives an empty list. Seeing that result helped me connect flatMap to actual values rather than memorizing a method name.
Run the lesson
The complete example is in ListComprehensionLesson.scala. From the lab root:
sbt "runMain learning.PremiumLesson"
This runs the earlier lessons too. The run passed assertions for the guard results, exact combination order, equivalent method calls, nested groups and empty inputs.
Next I will make the channel list depend on the current policy. For now, I can read the comprehension and explain precisely why its outer call is flatMap.
Reference: Scala 2.13 specification: for-comprehensions. This continues the language groundwork for our Scala 2.13.18, Play 3 and Apache Pekko course.