JavaRush /Courses /Kotlin SELF /Groupings in reports: list by key and counter by key

Groupings in reports: list by key and counter by key

Kotlin SELF
Level 24, Lesson 4
Available

1. Introduction

When we write a learning project, it’s very easy to get stuck on commands like “add record / show list / delete record” and be happy that at least it doesn’t crash. But the real “useful magic” begins when you can answer the user’s question: “what’s going on?”. Groupings are like taking a box of Lego parts and sorting them into containers: red ones separately, blue ones separately, and round ones can live their own life altogether.

In our practical console project (a hypothetical expense tracker), the data lives in a collection. That means reports also have to be “about collections”: no hand-written nested loops spanning three screens, and no state that becomes hard to debug later. Today we’ll neatly tie today’s techniques to project logic: we’ll learn once how to make a “list by key” and a “counter by key”, and after that you’ll start seeing these two result forms in almost any task.

2. Mini data model: an expense record

To show reports, we need some data. Since classes and data class will come later, for now we’ll use a simple structure based on Triple. We’ll agree that one expense record is:

  • category (String)
  • amount (Int)
  • note (String)

Let’s immediately make a small utility so the code in examples is readable. Note: this is not a new type, just a function that creates a Triple.

fun expense(category: String, amount: Int, note: String): Triple<String, Int, String> {
    return Triple(category, amount, note)
}

Now we can assemble test data:

fun main() {
    val expenses = listOf(
        expense("food", 120, "groceries"),
        expense("food", 80, "coffee"),
        expense("taxi", 250, "airport"),
        expense("books", 500, "kotlin")
    )

    println(expenses.size) // 4
}

The main idea: we have a flat list of records. But reports almost always want to see structure: either “sections” (groups) or a “summary” (frequencies).

3. Two result forms: list and counter by key

This is an important point: you don’t need to memorize a million functions. You only need to remember two “answer shapes” that business logic likes. They differ literally by what is stored in the map’s value.

Imagine the key is the “expense category”.

Then we have two classic results:

What we want to get What the result looks like How we usually do it
“Show all expenses by category”
Map<String, List<Triple<...>>>
groupBy { category }
“How many expenses are in each category?”
Map<String, Int>
groupingBy { category }.eachCount()

groupBy returns a map where the key is the result of the lambda and the value is a list of the group’s elements.
eachCount() returns a map where the value is simply the number of elements for the key.

To keep your brain from overheating, remember a simple analogy: groupBy is “folders with files”, and eachCount is “how many files are in each folder”.

A small diagram of how to think about it:

flowchart TD
    A["List⟨ExpenseRecord⟩ (flat list)"] --> B["groupBy { category }"]
    B --> C["Map⟨Category, List⟨ExpenseRecord⟩⟩ (details)"]

    A --> D["groupingBy { category }.eachCount()"]
    D --> E["Map⟨Category, Int⟩ (summary)"]

4. Practice: category reports

List by category via groupBy

Often the user wants not only “how many”, but also “what exactly”. For example: “Show what I had in the "food" category.” For this we need a grouping with details — groupBy.

Let’s group by category. In an expense record, the category is stored in first.

fun main() {
    val expenses = listOf(
        Triple("food", 120, "groceries"),
        Triple("food", 80, "coffee"),
        Triple("taxi", 250, "airport")
    )

    val byCategory: Map<String, List<Triple<String, Int, String>>> =
        expenses.groupBy { it.first }

    println(byCategory.keys) // [food, taxi]
}

Here it’s important to get used to the result type: Map<String, List<...>>. This is normal because “one category can have many records” — and that’s exactly a list.

Now let’s read the groups nicely. We don’t want to print the map “as is” (it doesn’t have to have a convenient order); we want a clear output: category, and under it — lines.

fun main() {
    val expenses = listOf(
        Triple("food", 120, "groceries"),
        Triple("food", 80, "coffee"),
        Triple("taxi", 250, "airport")
    )

    val byCategory = expenses.groupBy { it.first }

    for ((category, items) in byCategory) {
        println("== $category ==")            // == food == (order may differ)
        println("items: ${items.size}")       // items: 2
    }
}

Even such a “semi-report” is already useful: you can see that inside each group there is a regular list, and you can do everything familiar with it — size, sortedBy, for, and so on.

Counter by category via groupingBy().eachCount()

Sometimes you don’t need details. For example, the user asks: “In which categories do I have the most records overall?” Here, having a list per category is not required — we only need numbers.

And this is where groupingBy { ... }.eachCount() is perfect: it immediately creates a “frequency map” Map<K, Int>.

fun main() {
    val expenses = listOf(
        Triple("food", 120, "groceries"),
        Triple("food", 80, "coffee"),
        Triple("taxi", 250, "airport")
    )

    val counts: Map<String, Int> =
        expenses.groupingBy { it.first }.eachCount()

    println(counts) // {food=2, taxi=1}
}

Now you can sort by descending count and make a “top categories”. We remember the pattern: take entries, sort, print.

fun main() {
    val counts = mapOf("food" to 2, "taxi" to 1, "books" to 5)

    val sorted = counts.entries.sortedByDescending { it.value }
    for ((category, count) in sorted) {
        println("$category -> $count")
        // books -> 5
        // food -> 2
        // taxi -> 1
    }
}

Notice how much shorter the code becomes when you choose the right result form. If you need numbers — use Map<K, Int>. If you need details — use Map<K, List<T>>.

Two ways to count: from groups and directly

A very typical situation in a project: on the same data, you want two different reports.

Let’s imagine that we want:

  • “List by categories” (details)
  • “Number of records by categories” (summary)

And here you can feel the difference between two approaches:

  1. You can do groupBy, and then turn groups into sizes via mapValues { ... }.
  2. You can immediately do groupingBy().eachCount().

mapValues transforms the values of a map while keeping the keys — that’s useful when you already have groups and want to post-process them.

Let’s show both options side by side — not as “which is best forever”, but as “what is more appropriate in a specific situation”.

fun main() {
    val expenses = listOf(
        Triple("food", 120, "groceries"),
        Triple("food", 80, "coffee"),
        Triple("taxi", 250, "airport")
    )

    val groups = expenses.groupBy { it.first }
    val countsFromGroups = groups.mapValues { (_, items) -> items.size }

    val countsDirect = expenses.groupingBy { it.first }.eachCount()

    println(countsFromGroups) // {food=2, taxi=1}
    println(countsDirect)     // {food=2, taxi=1}
}

In real life, you choose like this: if the report truly needs both details and numbers, then first groupBy, and then you can do whatever you want from those groups (for example, size, sorting inside a group, printing). If the report needs only numbers, then eachCount() saves you from storing lists.

Normalizing the category key

Now comes the moment where half of beginners’ reports break. Grouping works honestly: the key is whatever you return from the lambda. If you returned "Taxi" and " taxi ", then for Kotlin those are different strings, and it will create two different groups or two different entries in the counter.

That’s why in a project, the key almost always needs to be normalized: remove whitespace and convert to a single case. Let’s immediately extract this into a function so we don’t write the same thing in five places.

fun normalizeCategory(raw: String): String {
    return raw.trim().lowercase()
}

Now use it in grouping:

fun main() {
    val expenses = listOf(
        Triple(" Taxi ", 250, "airport"),
        Triple("taxi", 300, "home"),
        Triple("TAXI", 150, "discount ride")
    )

    val counts = expenses.groupingBy { normalizeCategory(it.first) }.eachCount()
    println(counts) // {taxi=3}
}

It looks like a small detail, but in practice it feels like fastening a seat belt: you can go without it… right up until the first unpleasant incident.

5. Formatting and CLI integration

Formatting reports and StringBuilder

When the report is already “calculated”, the next grown-up question arises: “how do we show it nicely?” Printing a Map directly is tempting, but the user is not obligated to love the {taxi=3, food=2} format as much as Kotlin does.

Discipline helps here: compute data separately, build text separately.

Let’s make a function that forms a “list by category” report, and print categories in alphabetical order (this is usually clearer to a person).

import kotlin.text.StringBuilder

fun formatExpensesByCategory(groups: Map<String, List<Triple<String, Int, String>>>): String {
    val sb = StringBuilder()

    val sorted = groups.entries.sortedBy { it.key }
    for ((category, items) in sorted) {
        sb.appendLine("== $category (${items.size}) ==")
        for ((_, amount, note) in items) {
            sb.appendLine("- $amount : $note")
        }
    }

    return sb.toString()
}

Let’s test it on a small example:

fun main() {
    val expenses = listOf(
        Triple("food", 120, "groceries"),
        Triple("taxi", 250, "airport"),
        Triple("food", 80, "coffee")
    )

    val groups = expenses.groupBy { it.first }
    val report = formatExpensesByCategory(groups)

    println(report)
    // == food (2) ==
    // - 120 : groceries
    // - 80 : coffee
    // == taxi (1) ==
    // - 250 : airport
}

Notice the nice effect: the main main stays “clean” and short. And the formatting logic lives in a separate function — it’s much easier to change it without being afraid of breaking input and commands.

Now let’s do formatting for the frequency map. Here you usually want to sort by descending frequency and possibly show top-N. We already know take(n) from previous days, so we’ll use it without shame or remorse.

import kotlin.text.StringBuilder

fun formatCategoryCounts(counts: Map<String, Int>, topN: Int): String {
    val sb = StringBuilder()

    val top = counts.entries
        .sortedByDescending { it.value }
        .take(topN)

    for ((category, count) in top) {
        sb.appendLine("$category -> $count")
    }

    return sb.toString()
}

Example:

fun main() {
    val counts = mapOf("food" to 10, "taxi" to 3, "books" to 7)

    val report = formatCategoryCounts(counts, topN = 2)
    println(report)
    // food -> 10
    // books -> 7
}

It’s important to understand that we’re not sorting “the map itself”, but the list of entries entries. This is the clearest path: Map is about key→value mapping, and we define the “output order” ourselves, separately.

Connecting reports to the CLI

Now we’ll wire this into our console application. We’re not writing the entire project (otherwise the lecture would turn into a 48-season series), but we’ll show how to neatly connect two reports to the command logic you already know.

Suppose we have a list of expenses MutableList<Triple<String, Int, String>> and a command-reading loop. We’ll add two commands:

  • report byCategory — details by category
  • report counts — how many records are in categories (top-N, e.g., top-5)

Handler skeleton:

fun handleReportCommand(expenses: List<Triple<String, Int, String>>, args: List<String>) {
    val mode = args.getOrNull(0) ?: ""

    when (mode) {
        "byCategory" -> {
            val groups = expenses.groupBy { normalizeCategory(it.first) }
            println(formatExpensesByCategory(groups))
        }
        "counts" -> {
            val counts = expenses.groupingBy { normalizeCategory(it.first) }.eachCount()
            println(formatCategoryCounts(counts, topN = 5))
        }
        else -> {
            println("Unknown report mode: $mode") // Unknown report mode: ...
        }
    }
}

And an example of “how this might be called” from the main loop (simplified). Note: we assume that parsing a string into tokens (split, trim) is something you already have from previous lectures about commands.

fun main() {
    val expenses = mutableListOf(
        Triple("Food", 120, "groceries"),
        Triple(" taxi ", 250, "airport"),
        Triple("food", 80, "coffee")
    )

    val cmd = "report counts"
    val parts = cmd.trim().split(" ").filter { it.isNotBlank() }

    if (parts.firstOrNull() == "report") {
        handleReportCommand(expenses, parts.drop(1))
    }
}

The point here is not for you to memorize the “perfect command format”, but to see the architectural idea: the command selects the report, and the report selects the result form.

If you need detail — groupBy and then format the groups. If you need a summary — eachCount and format the frequency map.

6. Common mistakes

Mistake #1: choosing groupBy when you only need numbers, and then being surprised that everything became “heavy”.
Beginners often automatically reach for groupBy because it feels “clearer”. But the result Map<K, List<T>> stores whole lists of elements, and if you only need “how many”, that’s extra work and extra memory. In a project it looks like this: you make groups, then immediately take .size and throw away the details. In such cases it’s better to use groupingBy { ... }.eachCount() right away and get Map<K, Int>.

Mistake #2: not normalizing the key and getting “split apart” categories.
If the user enters categories by hand, you will almost certainly get "Food", " food", "FOOD" as different keys. Grouping works correctly, but the report looks as if the user has three different categories. This is fixed by ensuring normalization (trim().lowercase()) happens inside the key selector, not “somewhere later” when it’s already too late.

Mistake #3: reading groups via groups[key]!! and crashing on a missing key.
When you read a value from a Map, Kotlin fairly returns V? because the key might be absent. If in a report you do groups["food"]!!, and there is no "food" category (or it became " Food " and wasn’t normalized), you’ll get a NullPointerException right where the user wanted “just a report”. In report logic it’s better to iterate over entries or use safe options like groups[key].orEmpty().

Mistake #4: printing a Map directly and expecting a “nice” order.
It’s often tempting to do println(counts) and consider the task done. But the order in a map’s output is not part of the contract for a “clear report”. Users usually want either sorting by key (alphabetical) or sorting by value (descending frequency). So the normal path is: counts.entries.sortedBy... and only then print.

Mistake #5: mixing computations and formatting into one giant chain.
Collection operation chains are great as long as you can read them. When you cram normalization, grouping, sorting, take, string building, and printing into a single line, you get “puzzle code” that’s hard to change. It’s much more stable to split it into 2–3 intermediate vals: counts, sorted, reportText. That helps both you and makes debugging easier.

1
Task
Kotlin SELF, level 24, lesson 4
Locked
Expense categories
Expense categories
1
Task
Kotlin SELF, level 24, lesson 4
Locked
Frequencies from a string
Frequencies from a string
1
Task
Kotlin SELF, level 24, lesson 4
Locked
Category normalization
Category normalization
1
Task
Kotlin SELF, level 24, lesson 4
Locked
Well-formatted report
Well-formatted report
1
Survey/quiz
Groupings and frequencies, 24 level, 4 lesson
Unavailable
Groupings and frequencies
Groupings and frequencies
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION