Scala - List Operations (map, filter, flatMap, fold)

The `map` operation applies a function to each element in a List, producing a new List with transformed values. This is the workhorse of functional data transformation.

Key Insights

  • Scala’s List operations leverage immutability and functional composition, making data transformations predictable and thread-safe without defensive copying
  • Understanding the relationship between map, flatMap, and for comprehensions reveals how Scala’s syntactic sugar translates to monadic operations
  • The fold family (foldLeft, foldRight, reduce) provides powerful aggregation patterns, but choosing the wrong variant can impact stack safety and performance on large collections

Map: Transforming Elements

The map operation applies a function to each element in a List, producing a new List with transformed values. This is the workhorse of functional data transformation.

val numbers = List(1, 2, 3, 4, 5)
val doubled = numbers.map(_ * 2)
// Result: List(2, 4, 6, 8, 10)

case class User(name: String, age: Int)
val users = List(
  User("Alice", 30),
  User("Bob", 25),
  User("Charlie", 35)
)

val names = users.map(_.name)
// Result: List("Alice", "Bob", "Charlie")

val ageCategories = users.map { user =>
  if (user.age < 30) "young" else "experienced"
}
// Result: List("experienced", "young", "experienced")

Map preserves the structure of the collection—one input element always produces exactly one output element. The original List remains unchanged due to immutability.

Filter: Selecting Elements

Filter creates a new List containing only elements that satisfy a predicate function. It’s essential for data subsetting and validation pipelines.

val numbers = List(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
val evens = numbers.filter(_ % 2 == 0)
// Result: List(2, 4, 6, 8, 10)

val users = List(
  User("Alice", 30),
  User("Bob", 25),
  User("Charlie", 35),
  User("David", 28)
)

val experiencedUsers = users.filter(_.age >= 30)
// Result: List(User("Alice", 30), User("Charlie", 35))

// Combining filter with other operations
val experiencedUserNames = users
  .filter(_.age >= 30)
  .map(_.name)
  .map(_.toUpperCase)
// Result: List("ALICE", "CHARLIE")

Filter can reduce the size of your collection, but never increases it. Use filterNot for inverse predicates rather than negating conditions inside filter.

val odds = numbers.filterNot(_ % 2 == 0)
// Result: List(1, 3, 5, 7, 9)

FlatMap: Mapping and Flattening

FlatMap combines mapping with flattening, crucial when your transformation function returns a collection for each element. This operation is fundamental to monadic composition in Scala.

val words = List("Hello World", "Scala Programming")
val allWords = words.flatMap(_.split(" "))
// Result: List("Hello", "World", "Scala", "Programming")

// Without flatMap, you'd get nested Lists
val nested = words.map(_.split(" ").toList)
// Result: List(List("Hello", "World"), List("Scala", "Programming"))

case class Department(name: String, employees: List[String])
val departments = List(
  Department("Engineering", List("Alice", "Bob")),
  Department("Sales", List("Charlie", "David", "Eve"))
)

val allEmployees = departments.flatMap(_.employees)
// Result: List("Alice", "Bob", "Charlie", "David", "Eve")

FlatMap shines when handling optional values or error-prone operations:

def parseNumber(s: String): Option[Int] = 
  try { Some(s.toInt) } catch { case _: NumberFormatException => None }

val inputs = List("1", "2", "invalid", "4", "bad")
val validNumbers = inputs.flatMap(parseNumber)
// Result: List(1, 2, 4)

// Compare with map, which preserves the Option wrapper
val withOptions = inputs.map(parseNumber)
// Result: List(Some(1), Some(2), None, Some(4), None)

For comprehensions desugar to flatMap and map operations:

val result = for {
  dept <- departments
  emp <- dept.employees
} yield emp.toUpperCase

// Equivalent to:
val resultExplicit = departments.flatMap { dept =>
  dept.employees.map(_.toUpperCase)
}

FoldLeft: Left-to-Right Accumulation

FoldLeft traverses the List from left to right, accumulating a result. It’s tail-recursive and stack-safe for large collections.

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

// Sum using foldLeft
val sum = numbers.foldLeft(0)(_ + _)
// Result: 15

// More explicit version showing accumulator
val sumExplicit = numbers.foldLeft(0) { (accumulator, element) =>
  accumulator + element
}

// Building a different structure
val concatenated = numbers.foldLeft("") { (acc, n) =>
  acc + n.toString
}
// Result: "12345"

case class Transaction(amount: Double, category: String)
val transactions = List(
  Transaction(100.0, "food"),
  Transaction(50.0, "transport"),
  Transaction(200.0, "food"),
  Transaction(75.0, "entertainment")
)

val totalByCategory = transactions.foldLeft(Map.empty[String, Double]) {
  (acc, transaction) =>
    val currentTotal = acc.getOrElse(transaction.category, 0.0)
    acc + (transaction.category -> (currentTotal + transaction.amount))
}
// Result: Map("food" -> 300.0, "transport" -> 50.0, "entertainment" -> 75.0)

FoldLeft processes elements in order: ((((0 + 1) + 2) + 3) + 4) + 5. The accumulator type can differ from the element type, enabling powerful transformations.

FoldRight: Right-to-Left Accumulation

FoldRight traverses from right to left. It’s not tail-recursive, making it unsuitable for large Lists due to stack overflow risk.

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

// Different associativity than foldLeft
val result = numbers.foldRight(0)(_ - _)
// Computes: 1 - (2 - (3 - (4 - (5 - 0))))
// Result: 3

val leftResult = numbers.foldLeft(0)(_ - _)
// Computes: ((((0 - 1) - 2) - 3) - 4) - 5
// Result: -15

// Building Lists preserves order
val doubled = numbers.foldRight(List.empty[Int]) { (elem, acc) =>
  (elem * 2) :: acc
}
// Result: List(2, 4, 6, 8, 10)

Use foldRight when operation order matters or when building right-associative structures:

def flatten[A](lists: List[List[A]]): List[A] = 
  lists.foldRight(List.empty[A])(_ ++ _)

val nested = List(List(1, 2), List(3, 4), List(5))
flatten(nested)
// Result: List(1, 2, 3, 4, 5)

Reduce: Folding Without Initial Value

Reduce operations fold without an explicit initial value, using the first element instead. This throws exceptions on empty Lists.

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

val sum = numbers.reduce(_ + _)
// Result: 15

val max = numbers.reduce((a, b) => if (a > b) a else b)
// Result: 5

// reduceLeft and reduceRight specify direction
val leftReduction = numbers.reduceLeft(_ - _)
// Computes: (((1 - 2) - 3) - 4) - 5
// Result: -13

val rightReduction = numbers.reduceRight(_ - _)
// Computes: 1 - (2 - (3 - (4 - 5)))
// Result: 3

For safer code, use reduceOption or reduceLeftOption:

val empty = List.empty[Int]
val safeSum = empty.reduceLeftOption(_ + _)
// Result: None

val safeMax = numbers.reduceOption((a, b) => if (a > b) a else b)
// Result: Some(5)

Chaining Operations

Real-world data pipelines chain multiple operations. Scala’s collection methods return new collections, enabling fluent composition:

case class Order(id: Int, amount: Double, status: String, items: List[String])

val orders = List(
  Order(1, 150.0, "completed", List("laptop", "mouse")),
  Order(2, 75.0, "pending", List("keyboard")),
  Order(3, 300.0, "completed", List("monitor", "cable", "stand")),
  Order(4, 50.0, "cancelled", List("mousepad"))
)

val completedOrderValue = orders
  .filter(_.status == "completed")
  .map(_.amount)
  .foldLeft(0.0)(_ + _)
// Result: 450.0

val allItemsFromLargeOrders = orders
  .filter(_.amount > 100.0)
  .flatMap(_.items)
  .map(_.toUpperCase)
  .distinct
// Result: List("LAPTOP", "MOUSE", "MONITOR", "CABLE", "STAND")

val orderSummary = orders
  .filter(_.status == "completed")
  .foldLeft(Map.empty[Int, Int]) { (acc, order) =>
    acc + (order.items.length -> (acc.getOrElse(order.items.length, 0) + 1))
  }
// Result: Map(2 -> 1, 3 -> 1) - counts of orders by item count

Each operation creates an intermediate List. For performance-critical code with many transformations, consider using view to create a lazy collection or switching to Vector for better performance characteristics on large datasets.

Liked this? There's more.

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