Folds in Scala: turning collections into results
A type-led guide to foldLeft, foldRight, reduce and scan, with accumulator design, evaluation order, performance and practical alternatives.
A fold replaces a collection with accumulated state
foldLeft begins with an initial value and combines it with each collection element from left to right. The accumulator can be a number, collection, case class or any other type representing the state produced so far.
The initial value handles empty input and fixes the accumulator type. That makes fold more general and safer than reduce, which starts from an element of the collection and cannot produce a result for an empty collection without wrapping or throwing.
Read a fold by naming the accumulator invariant: total is the sum already visited, or byId contains every account processed so far. If that sentence is unclear, the fold is probably doing too much.
val total: BigDecimal = payments.foldLeft(BigDecimal(0)) {
case (runningTotal, payment) =>
runningTotal + payment.amount
}
val byId: Map[AccountId, Account] = accounts.foldLeft(Map.empty[AccountId, Account]) {
case (result, account) => result.updated(account.id, account)
}foldLeft and foldRight associate differently
foldLeft evaluates the accumulator from the beginning: ((zero op a) op b) op c. foldRight associates from the other end: a op (b op (c op zero)). The difference matters for non-associative operations and when constructing ordered results.
In strict Scala collections, do not assume foldRight is a general solution for lazy short-circuiting. Its behaviour depends on the collection implementation and function signature. Methods such as exists, find and collectFirst communicate early termination more directly.
For List construction, foldRight with :: preserves order, while foldLeft with :: reverses it. A left fold can prepend efficiently and call reverse once when stack behaviour or a more natural forward traversal matters.
val values = List(1, 2, 3)
val preserved: List[String] =
values.foldRight(List.empty[String]) { (value, result) =>
value.toString :: result
}
val reversed: List[String] =
values.foldLeft(List.empty[String]) { (result, value) =>
value.toString :: result
}
// preserved == List("1", "2", "3")
// reversed == List("3", "2", "1")reduce has no independent empty value
reduceLeft and reduceRight combine elements using an operation whose input and result are tied to the collection element type. Calling reduce on an empty collection throws UnsupportedOperationException; reduceOption returns None instead.
Use reduce when the domain guarantees at least one value and combining like values is the actual operation. Use fold when empty input has a meaningful result or the accumulator has a different type.
Operations such as subtraction expose direction immediately. Even associative operations may not be safe to reorder when floating-point rounding or side effects are involved. Keep fold functions pure so traversal order is not also controlling hidden external behaviour.
val values = List(10, 3, 2)
values.reduceLeft(_ - _) // (10 - 3) - 2 == 5
values.reduceRight(_ - _) // 10 - (3 - 2) == 9
val maximum: Option[Int] = values.reduceOption(_ max _)
val total: Int = List.empty[Int].foldLeft(0)(_ + _)A named accumulator can rescue a dense fold
Tuples are acceptable for two obvious values, but a fold accumulating several counters, collections and flags becomes positional code. A case class names each part of the state and provides a natural place to document the invariant.
Avoid repeatedly copying large immutable collections inefficiently. Vector appends are reasonable; List prepends are cheap; repeated List :+ is not. For performance-sensitive construction, use an appropriate builder and return an immutable value at the boundary.
Do not perform Future or IO work inside an ordinary fold and assume it has been sequenced. The accumulator type must carry the effect, or a library traversal such as traverse should express the intended ordering and failure semantics.
final case class Summary(
acceptedTotal: BigDecimal,
rejectedIds: Vector[PaymentId]
)
val summary = payments.foldLeft(Summary(BigDecimal(0), Vector.empty)) {
case (state, payment) if payment.accepted =>
state.copy(acceptedTotal = state.acceptedTotal + payment.amount)
case (state, payment) =>
state.copy(rejectedIds = state.rejectedIds :+ payment.id)
}scan exposes every intermediate accumulator
scanLeft behaves like foldLeft but returns the initial value and every intermediate state. It is useful for running totals, state histories and debugging a transition. The result contains one more element than the input because it includes the seed.
A scan can consume substantial memory because it retains every state. Use fold when only the final result matters, and Iterator or streaming abstractions when intermediate states must be processed without materialising them all.
Standard operations are usually clearer than clever folds. Prefer map for one-to-one transformation, filter for selection, groupMapReduce for grouped aggregation and find for the first match. Reach for fold when a genuine state transition connects the elements.
val balances: List[BigDecimal] =
transactions.scanLeft(openingBalance) {
case (balance, Credit(amount)) => balance + amount
case (balance, Debit(amount)) => balance - amount
}
// One seed plus one balance for every transaction.Review the algebra, not only the syntax
Parallel or regrouped folding is safe only when the combination operation has the required identity and associativity. String concatenation and numeric addition have familiar identities; a process that sends notifications or depends on element order is not a lawful parallel reduction.
A fold should make input, state and transition visible. If it contains returns, exceptions used for early exit or mutable state outside the accumulator, use a more specific collection method or an explicit loop.
Test empty, singleton and representative multi-element input. When order matters, include values that make an accidental reversal visible rather than only comparing totals.
- What does the accumulator mean after each element?
- What is the correct result for empty input?
- Does left-to-right or right-to-left order matter?
- Is the combination associative, or must it stay sequential?
- Is a named state clearer than a tuple?
- Would map, find, exists or groupMapReduce state the intent better?