JavaRush /Courses /Kotlin SELF /Aggregations with fold: sums, minimums, and building stru...

Aggregations with fold: sums, minimums, and building structures

Kotlin SELF
Level 25, Lesson 1
Available

1. Aggregation and fold as a general technique

When you’re just starting to program, it feels like almost every problem is solved the same way: “take a loop, create var result, accumulate inside the loop.” And that’s true… until you have lots of those loops, and you start getting confused about what you’re accumulating where, and why it suddenly got “accidentally” reused.

Aggregation is a neat name for the technique “many elements → one final value.” The final value can be a sum, a minimum, a report string, a dictionary of totals by category—anything. What matters is not what exactly we compute, but how we think about it: we have an accumulator (an intermediate result) and a rule for how to update this accumulator for each next element.

fold as a universal “result constructor”

In a nutshell, fold is “iterate a collection where you define what the final result type will be.” And the final type (R) does not have to match the element type (T) at all. That’s the key idea: we’re not just “summing numbers,” we’re “building a result in the shape we need.”

From the contract point of view, fold takes a start value and a combiner function that each time receives the old accumulator and a new element, and returns a new accumulator. In the docs, this reads directly as “take the accumulated value and the element → get a new accumulated value.”

Let’s look at the scheme so your brain doesn’t try to treat it like a “black box”:

flowchart TD
    A["initial (acc)"] --> B["combine(acc, x1) -> acc1"]
    B --> C["combine(acc1, x2) -> acc2"]
    C --> D["combine(acc2, x3) -> acc3"]
    D --> E["... final accN"]

If you’re used to loops, you can think of it like this: fold is a “civilized loop” where instead of an external var, you must return the new accumulator value.

Neutral element and choosing initial

In fold(initial) { acc, x -> ... } the initial parameter is not “just some number to start with,” but part of the meaning of your operation. It’s often called the neutral element: a start value that doesn’t distort the result.

If you’re computing a sum, the neutral element is 0, because 0 + x = x. If you’re computing a product, it’s 1, because 1 * x = x. If you’re building a string, the neutral element is most often the empty string "" or StringBuilder() (depending on the approach). If you’re building a Map, the neutral element is an empty map.

Below is a small table of “common aggregates → good initial”:

What we build Result type initial Why
Sum
Int / Long / Double
0 / 0L / 0.0
Zero does not affect the sum
Product
Int / Long / Double
1 / 1L / 1.0
One does not affect the product
Text concatenation
String
""
An empty string adds no characters
Large text
StringBuilder
StringBuilder()
We “grow” the object
Accumulation by key
MutableMap<K, V>
mutableMapOf()
We start with an empty structure

The most unpleasant part is that an incorrect initial often doesn’t cause a compilation error. The program “works,” it just produces nonsense. It’s the kind of bug that doesn’t crash—it quietly laughs at you.

2. Basic aggregations using expenses as an example

So the examples aren’t “spherical numbers in a vacuum,” let’s continue our practical console expense-tracking project. Still without OOP: an expense is a Pair<String, Int>, where first is the category, and second is the amount in arbitrary units.

Let’s start with a simple dataset:

fun main() {
    val expenses: List<Pair<String, Int>> = listOf(
        "food" to 120,
        "taxi" to 250,
        "food" to 80,
        "books" to 540
    )

    println(expenses)
    // [(food, 120), (taxi, 250), (food, 80), (books, 540)]
}

Total sum via fold

Now let’s compute the total sum of all expenses. This is the classic “many numbers → one number”:

fun main() {
    val expenses = listOf(
        "food" to 120,
        "taxi" to 250,
        "food" to 80,
        "books" to 540
    )

    val total = expenses.fold(0) { acc, (_, amount) ->
        acc + amount
    }

    println("Total: $total") // Total: 990
}

Notice a small detail: we destructure the pair as (_, amount). The category does not participate in the sum, so _ is an honest signal: “I’m ignoring this.”

If you caught yourself thinking, “Why not sumOf?”—that’s a reasonable thought. But today we’re deliberately practicing the fold pattern: it will come in handy when there is no ready-made “make it pretty for me” function.

Counting with a condition via fold

Kotlin has count { ... }, and in real life that’s what you’ll use. But to truly understand fold, it’s useful once to build count by hand—to see that “aggregation” doesn’t have to be summing money; it can be summing facts.

For example: how many expenses are greater than 200?

fun main() {
    val expenses = listOf(
        "food" to 120,
        "taxi" to 250,
        "food" to 80,
        "books" to 540
    )

    val bigCount = expenses.fold(0) { acc, (_, amount) ->
        if (amount > 200) acc + 1 else acc
    }

    println("Big expenses: $bigCount") // Big expenses: 2
}

Here the accumulator is just a number: “how many we’ve found so far.” We increment it when we meet a matching element.

3. Minimum and maximum as aggregations

Minimum/maximum is a classic type of task where beginners often go two ways: either they sort the list (expensive and unnecessary), or they write a loop with var min = ... and then run into the problem: “What do I do if the list is empty?”

Today we’ll look at a practical “fold-style” minimum that immediately accounts for the empty case. The idea is simple: make the accumulator nullable.

For example, let’s find the minimum expense amount:

fun main() {
    val expenses = listOf(
        "food" to 120,
        "taxi" to 250,
        "food" to 80,
        "books" to 540
    )

    val minAmount: Int? = expenses.fold(null as Int?) { acc, (_, amount) ->
        if (acc == null || amount < acc) amount else acc
    }

    println("Min amount: $minAmount") // Min amount: 80
}

The advantage of this approach is that it is correct even for an empty list:

fun main() {
    val empty = emptyList<Pair<String, Int>>()

    val minAmount: Int? = empty.fold(null as Int?) { acc, (_, amount) ->
        if (acc == null || amount < acc) amount else acc
    }

    println("Min amount: $minAmount") // Min amount: null
}

That is, you honestly say: “If there is no data, there is no result.” This is better than “If there is no data, let’s pretend the minimum is a billion,” because billions tend to quietly sneak into reports later.

4. Building text with fold and StringBuilder

In console programs, sooner or later you get the urge to output things “like an adult app”: multiple lines, alignment, a clean total. And it’s easy to fall into the trap of endless string concatenation via +, which turns the code into spaghetti.

fold is great for assembling a report, especially if you use StringBuilder as the accumulator. This is the case where the result is not a number, but text.

Let’s assemble the simplest report: each purchase is a separate line.

fun main() {
    val expenses = listOf(
        "food" to 120,
        "taxi" to 250,
        "food" to 80
    )

    val report = expenses.fold(StringBuilder()) { acc, (category, amount) ->
        acc.append(category)
            .append(": ")
            .append(amount)
            .append('\n')
    }.toString()

    print(report)
    // food: 120
    // taxi: 250
    // food: 80
}

Notice the “trick”: the lambda returns a StringBuilder. The append method returns the same object, so acc.append(...).append(...) stays the same accumulator—we just “grow” it.

Now let’s add a total line to the report. A two-step approach looks nice here: compute the sum separately, format the text separately. The code reads more easily.

fun main() {
    val expenses = listOf(
        "food" to 120,
        "taxi" to 250,
        "food" to 80
    )

    val total = expenses.fold(0) { acc, (_, amount) -> acc + amount }

    val report = expenses.fold(StringBuilder()) { acc, (category, amount) ->
        acc.append("- ")
            .append(category)
            .append(": ")
            .append(amount)
            .append('\n')
    }.append("Total: ").append(total).append('\n')
     .toString()

    print(report)
    // - food: 120
    // - taxi: 250
    // - food: 80
    // Total: 450
}

Yes, you could do everything in one fold, but then “side computations” appear inside the lambda, which are harder to maintain. When you’re learning, it’s better to keep computation and formatting separate: your brain likes clean architecture too.

5. Building structures and combining aggregations

Accumulating sums by category via Map

Now the best part: fold can build not only “one number” or “one text,” but an entire structure. For expense tracking, this is a direct hit: “category → total for the category.”

We want to get a Map<String, Int>:

  • "food" -> 200
  • "taxi" -> 250
  • "books" -> 540

Let’s do this with fold, where the accumulator is a MutableMap<String, Int>.

fun main() {
    val expenses = listOf(
        "food" to 120,
        "taxi" to 250,
        "food" to 80,
        "books" to 540
    )

    val totalsByCategory: Map<String, Int> =
        expenses.fold(mutableMapOf<String, Int>()) { acc, (category, amount) ->
            acc[category] = (acc[category] ?: 0) + amount
            acc
        }

    println(totalsByCategory) // {food=200, taxi=250, books=540}
}

It’s important to understand why we return acc at the end. A fold lambda must return the “next accumulator value.” We modified acc (because it’s a mutable object), but fold still expects a return value to pass into the next step. That’s why the pattern looks like: “updated acc → returned acc.”

At first this feels strange: “I already changed the map; why return it?” The answer is simple: fold works the same way for all accumulator types—immutable and mutable. Its contract is always “return the new accumulator.”

From structure to result: “the most expensive category”

Let’s imagine we already have a “category → total” map:

val totalsByCategory = mapOf(
    "food" to 200,
    "taxi" to 250,
    "books" to 540
)

Now suppose we want to know “which category is the most expensive” and print the result. Let’s do it via fold, carefully handling the empty case.

fun main() {
    val totalsByCategory = mapOf(
        "food" to 200,
        "taxi" to 250,
        "books" to 540
    )

    val best: Pair<String, Int>? =
        totalsByCategory.entries.fold(null as Pair<String, Int>?) { acc, e ->
            val current = e.key to e.value
            if (acc == null || current.second > acc.second) current else acc
        }

    println(best) // (books, 540)
}

Here we aggregate entries, because we need both the key and the value. We store a Pair<String, Int> in the accumulator, i.e., “the best pair found so far.”

Refactoring: aggregator functions instead of “magic in main”

When you have more than one or two aggregations, main starts to look like a kitchen where code is frying, logic is boiling, and someone is also trying to print a report. Let’s extract aggregators into functions. This makes the program more readable and builds the habit that “code lives in functions, not in main.”

Let’s make three functions:

  • totalAmount(expenses) — sum of all expenses
  • minAmountOrNull(expenses) — minimum amount
  • totalsByCategory(expenses) — map of totals by category
fun totalAmount(expenses: List<Pair<String, Int>>): Int =
    expenses.fold(0) { acc, (_, amount) -> acc + amount }

fun minAmountOrNull(expenses: List<Pair<String, Int>>): Int? =
    expenses.fold(null as Int?) { acc, (_, amount) ->
        if (acc == null || amount < acc) amount else acc
    }

fun totalsByCategory(expenses: List<Pair<String, Int>>): Map<String, Int> =
    expenses.fold(mutableMapOf<String, Int>()) { acc, (category, amount) ->
        acc[category] = (acc[category] ?: 0) + amount
        acc
    }

And now main becomes much calmer:

fun main() {
    val expenses = listOf(
        "food" to 120,
        "taxi" to 250,
        "food" to 80,
        "books" to 540
    )

    val total = totalAmount(expenses)
    val min = minAmountOrNull(expenses)
    val byCategory = totalsByCategory(expenses)

    println("Total: $total")            // Total: 990
    println("Min expense: $min")        // Min expense: 80
    println("By category: $byCategory") // By category: {food=200, taxi=250, books=540}
}

This style also reduces the chance of mistakes: each aggregator can be tested separately on a small dataset, and you’ll catch logic bugs faster.

6. Common mistakes when using fold as a pattern

Mistake #1: incorrect initial (neutral element).
A very common situation: you wanted to compute a product, but you wrote fold(0) { acc, x -> acc * x }, and you always get zero. The compiler doesn’t complain, the program runs, but the result is guaranteed to be wrong. Train yourself to say out loud: “What should the value be if there are no elements?”—and choose initial from that.

Mistake #2: “I mutate a MutableMap, but I forgot to return acc.”
If inside fold you use a mutable structure as the accumulator, you still must return something from the lambda. Beginners sometimes update the map, but accidentally leave an expression like acc[category] = ... as the last line, and then the lambda returns Int? instead of the map, and the code breaks due to type mismatch. The rule is simple: at the end of the lambda for mutable accumulators, explicitly write acc.

Mistake #3: trying to compute “minimum” via fold(Int.MAX_VALUE) without thinking about the empty list.
This works as long as the list is not empty. As soon as the list is empty, you output Int.MAX_VALUE as the “minimum,” and then it lives in reports as a legitimate result. It’s much more honest to make the accumulator nullable and return null when there is no data, or to use safe contracts like ...OrNull, because an empty result is important information.

Mistake #4: an overly complex lambda in fold that does everything at once.
fold is powerful, and that creates temptation: “Now I’ll compute, filter, format, and also write to a file.” As a result, the lambda becomes a mini-program. If you see the lambda growing, stop and extract pieces into functions, or at least into local vals inside the lambda. fold likes clarity: (acc, x) -> newAcc, without a one-person show.

Mistake #5: confusing the roles of acc and x (and “reversed” logic).
Sometimes people start comparing acc with acc, adding x to x, and end up with “something weird.” Naming discipline really helps: name parameters so the roles are visible: acc and expense, acc and amount, best and candidate. And don’t hesitate to destructure the pair right in the parameters—this reduces cognitive load.

1
Task
Kotlin SELF, level 25, lesson 1
Locked
Sum from a string
Sum from a string
1
Task
Kotlin SELF, level 25, lesson 1
Locked
Pluses in the Report
Pluses in the Report
1
Task
Kotlin SELF, level 25, lesson 1
Locked
Minimum price
Minimum price
1
Task
Kotlin SELF, level 25, lesson 1
Locked
Text receipt
Text receipt
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION