Scala - For Loop and For Comprehension

• Scala's for-comprehensions are syntactic sugar that translate to `map`, `flatMap`, `withFilter`, and `foreach` operations, making them more powerful than traditional loops

Key Insights

• Scala’s for-comprehensions are syntactic sugar that translate to map, flatMap, withFilter, and foreach operations, making them more powerful than traditional loops • For-loops with yield transform collections functionally while loops without yield execute side effects imperatively • Understanding the desugaring process reveals how for-comprehensions work with any type implementing the right methods, not just collections

Basic For Loop Syntax

Scala provides two distinct forms of for expressions. The imperative form executes side effects without returning values:

val numbers = List(1, 2, 3, 4, 5)

// Imperative form - no yield
for (n <- numbers) {
  println(n * 2)
}
// Output: 2, 4, 6, 8, 10

The functional form with yield transforms collections and returns new collections:

val doubled = for (n <- numbers) yield n * 2
// doubled: List[Int] = List(2, 4, 6, 8, 10)

val squared = for {
  n <- numbers
} yield n * n
// squared: List[Int] = List(1, 4, 9, 16, 25)

Both syntaxes are valid. The curly brace style is preferred for multi-line comprehensions.

Guards and Filtering

Guards filter elements using if conditions without requiring separate filter calls:

val numbers = 1 to 20

// Filter even numbers
val evens = for {
  n <- numbers
  if n % 2 == 0
} yield n
// evens: IndexedSeq[Int] = Vector(2, 4, 6, 8, 10, 12, 14, 16, 18, 20)

// Multiple guards
val filtered = for {
  n <- numbers
  if n % 2 == 0
  if n > 10
} yield n
// filtered: IndexedSeq[Int] = Vector(12, 14, 16, 18, 20)

Multiple guards are AND conditions. This code desugars to withFilter calls:

// Equivalent desugared version
val filtered = numbers
  .withFilter(n => n % 2 == 0)
  .withFilter(n => n > 10)
  .map(n => n)

Nested Iterations

For-comprehensions excel at nested iterations, producing cartesian products or filtered combinations:

val colors = List("red", "green", "blue")
val sizes = List("S", "M", "L")

val products = for {
  color <- colors
  size <- sizes
} yield s"$color-$size"
// products: List[String] = List(red-S, red-M, red-L, green-S, green-M, green-L, blue-S, blue-M, blue-L)

// With filtering
val limitedProducts = for {
  color <- colors
  if color != "green"
  size <- sizes
  if size != "S"
} yield s"$color-$size"
// limitedProducts: List[String] = List(red-M, red-L, blue-M, blue-L)

This desugars to nested flatMap and map operations:

val products = colors.flatMap { color =>
  sizes.map { size =>
    s"$color-$size"
  }
}

Variable Definitions

Define intermediate variables within for-comprehensions using = assignments:

case class Person(firstName: String, lastName: String, age: Int)

val people = List(
  Person("John", "Doe", 30),
  Person("Jane", "Smith", 25),
  Person("Bob", "Johnson", 35)
)

val formatted = for {
  person <- people
  fullName = s"${person.firstName} ${person.lastName}"
  if person.age > 26
} yield s"$fullName is ${person.age} years old"
// formatted: List[String] = List(John Doe is 30 years old, Bob Johnson is 35 years old)

Intermediate variables avoid repetition and improve readability. They’re available to subsequent guards and the yield expression.

Pattern Matching in Generators

Extract tuple elements or case class fields directly in generators:

val pairs = List((1, "one"), (2, "two"), (3, "three"))

val extracted = for {
  (num, word) <- pairs
  if num % 2 != 0
} yield s"$num: $word"
// extracted: List[String] = List(1: one, 3: three)

case class User(id: Int, name: String, active: Boolean)
val users = List(
  User(1, "Alice", true),
  User(2, "Bob", false),
  User(3, "Charlie", true)
)

val activeNames = for {
  User(_, name, true) <- users
} yield name
// activeNames: List[String] = List(Alice, Charlie)

Non-matching elements are silently filtered out, equivalent to combining collect with pattern matching.

Working with Options

For-comprehensions elegantly handle Option chaining, replacing nested flatMap calls:

def findUser(id: Int): Option[String] = {
  if (id > 0) Some(s"User$id") else None
}

def findEmail(user: String): Option[String] = {
  if (user.nonEmpty) Some(s"${user.toLowerCase}@example.com") else None
}

def findDomain(email: String): Option[String] = {
  email.split("@").lastOption
}

// For-comprehension approach
val result = for {
  user <- findUser(42)
  email <- findEmail(user)
  domain <- findDomain(email)
} yield domain.toUpperCase
// result: Option[String] = Some(EXAMPLE.COM)

// Equivalent flatMap chain
val resultExplicit = findUser(42)
  .flatMap(user => findEmail(user))
  .flatMap(email => findDomain(email))
  .map(domain => domain.toUpperCase)

If any step returns None, the entire comprehension short-circuits and returns None.

Combining Different Monadic Types

For-comprehensions work with any type implementing map and flatMap. Mix compatible types:

import scala.concurrent.Future
import scala.concurrent.ExecutionContext.Implicits.global

def fetchUserId(): Future[Int] = Future.successful(123)
def fetchUserData(id: Int): Future[String] = Future.successful(s"Data for $id")

val result: Future[String] = for {
  id <- fetchUserId()
  data <- fetchUserData(id)
} yield s"Retrieved: $data"

// With Try
import scala.util.{Try, Success, Failure}

def parseNumber(s: String): Try[Int] = Try(s.toInt)
def divide(a: Int, b: Int): Try[Double] = Try(a.toDouble / b)

val calculation = for {
  num1 <- parseNumber("10")
  num2 <- parseNumber("2")
  result <- divide(num1, num2)
} yield result
// calculation: Try[Double] = Success(5.0)

Custom Types with For-Comprehensions

Implement map, flatMap, and withFilter to make custom types work with for-comprehensions:

case class Box[A](value: A) {
  def map[B](f: A => B): Box[B] = Box(f(value))
  
  def flatMap[B](f: A => Box[B]): Box[B] = f(value)
  
  def withFilter(p: A => Boolean): Box[A] = {
    if (p(value)) this else throw new NoSuchElementException("Predicate not satisfied")
  }
}

val result = for {
  x <- Box(10)
  y <- Box(20)
  if x + y > 25
} yield x + y
// result: Box[Int] = Box(30)

This demonstrates that for-comprehensions are not limited to collections—they’re a general pattern for sequential computation.

Performance Considerations

For-comprehensions create intermediate collections in each step. For performance-critical code, consider alternatives:

val numbers = (1 to 1000000).toList

// Creates intermediate collections
val result1 = for {
  n <- numbers
  if n % 2 == 0
  doubled = n * 2
  if doubled > 100
} yield doubled

// More efficient with view (lazy evaluation)
val result2 = numbers.view
  .filter(_ % 2 == 0)
  .map(_ * 2)
  .filter(_ > 100)
  .toList

// Or using while loop for maximum performance
def imperativeVersion(): List[Int] = {
  val buffer = scala.collection.mutable.ListBuffer[Int]()
  var i = 0
  while (i < numbers.length) {
    val n = numbers(i)
    if (n % 2 == 0) {
      val doubled = n * 2
      if (doubled > 100) buffer += doubled
    }
    i += 1
  }
  buffer.toList
}

Use for-comprehensions for clarity and maintainability. Optimize only when profiling indicates bottlenecks.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.