Skip to content

05 · Collections Deep Dive

Level 1 covered List, Set, and Map themselves. This module is about the functional operations that make Kotlin collections so pleasant to work with — chains of map/filter/ reduce that replace hand-written loops — plus sequences, which change when those operations run and can matter a lot for performance.

map, filter, and chaining

map transforms each element; filter keeps only elements matching a predicate. Both return a new collection, so they chain naturally.

data class Product(val name: String, val price: Double, val inStock: Boolean)

fun main() {
    val products = listOf(
        Product("Widget", 9.99, true),
        Product("Gadget", 24.99, false),
        Product("Gizmo", 14.50, true),
        Product("Doohickey", 5.00, true)
    )

    val affordableInStockNames = products
        .filter { it.inStock }
        .filter { it.price < 20.0 }
        .map { it.name }

    println(affordableInStockNames)   // [Widget, Gizmo, Doohickey]
}
[Widget, Gizmo, Doohickey]

reduce and fold

Both combine every element into a single value, but fold takes an explicit starting value and reduce uses the first element as its starting point.

fun main() {
    val prices = listOf(9.99, 24.99, 14.50, 5.00)

    val total = prices.fold(0.0) { acc, price -> acc + price }
    println(total)   // 54.48

    val maxPrice = prices.reduce { acc, price -> if (price > acc) price else acc }
    println(maxPrice)   // 24.99

    // reduce throws on an empty collection -- there's no "first element" to start from
    val empty = emptyList<Double>()
    // empty.reduce { acc, x -> acc + x }   // throws UnsupportedOperationException
    println(empty.fold(0.0) { acc, x -> acc + x })   // 0.0 -- fold handles empty fine
}
54.48
24.99
0.0

fold is the safer default: it always has a well-defined answer for an empty collection, since you supply the starting value yourself.

groupBy, associateBy, and partition

These reshape a flat list into a map or a pair of lists — extremely common for reporting and bucketing.

data class Employee(val name: String, val department: String, val salary: Int)

fun main() {
    val employees = listOf(
        Employee("Alice", "Engineering", 95000),
        Employee("Bob", "Sales", 60000),
        Employee("Carol", "Engineering", 105000),
        Employee("Dave", "Sales", 65000)
    )

    val byDept = employees.groupBy { it.department }
    println(byDept.keys)                       // [Engineering, Sales]
    println(byDept["Engineering"]?.map { it.name })   // [Alice, Carol]

    val byName = employees.associateBy { it.name }
    println(byName["Bob"]?.salary)              // 60000

    val (highEarners, others) = employees.partition { it.salary > 70000 }
    println(highEarners.map { it.name })        // [Alice, Carol]
    println(others.map { it.name })              // [Bob, Dave]
}
[Engineering, Sales]
[Alice, Carol]
60000
[Alice, Carol]
[Bob, Dave]

flatMap: flattening nested collections

map alone would leave you with a list of lists; flatMap merges them into one flat list.

data class Order(val id: Int, val items: List<String>)

fun main() {
    val orders = listOf(
        Order(1, listOf("apple", "banana")),
        Order(2, listOf("cherry")),
        Order(3, listOf("date", "elderberry"))
    )

    val allItems = orders.flatMap { it.items }
    println(allItems)   // [apple, banana, cherry, date, elderberry]

    // compare to plain map, which would give a List<List<String>>:
    println(orders.map { it.items })   // [[apple, banana], [cherry], [date, elderberry]]
}
[apple, banana, cherry, date, elderberry]
[[apple, banana], [cherry], [date, elderberry]]

Sorting with sortedBy and comparators

data class Person(val name: String, val age: Int)

fun main() {
    val people = listOf(Person("Bob", 25), Person("Alice", 30), Person("Carol", 25))

    println(people.sortedBy { it.age })                 // youngest first
    println(people.sortedByDescending { it.age })        // oldest first

    // sort by age, then by name for ties -- compareBy supports multiple keys
    val sorted = people.sortedWith(compareBy({ it.age }, { it.name }))
    println(sorted)
}
[Person(name=Bob, age=25), Person(name=Carol, age=25), Person(name=Alice, age=30)]
[Person(name=Alice, age=30), Person(name=Bob, age=25), Person(name=Carol, age=25)]
[Person(name=Bob, age=25), Person(name=Carol, age=25), Person(name=Alice, age=30)]

Sequences: lazy evaluation

Every operation on a regular List (map, filter, ...) runs eagerly and builds a brand-new intermediate list right away. Chain several of them and you allocate one throwaway list per step. asSequence() switches to lazy evaluation: nothing runs until you ask for a final result (like .toList(), .first(), or .sum()), and elements flow through the whole chain one at a time instead of one full pass per operation.

fun main() {
    val numbers = (1..1_000_000).toList()

    // Eager: filter builds a full intermediate list of ~500,000 elements,
    // THEN map builds another full list from that, THEN first() looks at it.
    val eagerResult = numbers.filter { it % 2 == 0 }.map { it * it }.first()

    // Lazy: each number flows through filter -> map one at a time; evaluation
    // stops the instant the first match is found -- no full intermediate lists.
    val lazyResult = numbers.asSequence()
        .filter { it % 2 == 0 }
        .map { it * it }
        .first()

    println(eagerResult)   // 4
    println(lazyResult)    // 4 -- same answer, far less allocation for large inputs
}
4
4

Sequences aren't automatically faster for everything

For small collections, or when you need to fully process every element anyway (e.g. .map { }.toList() over 10 items), the eager List version is simpler and just as fast — sequence setup has its own small overhead. Reach for asSequence() when you have a long chain of operations over a large collection, especially if an early operation (like first(), find(), or take()) can short-circuit before processing everything.

How It Actually Works

filter { it.inStock }.filter { it.price < 20.0 }.map { it.name } on a plain List is eager and multi-pass: each of those three calls fully iterates its input and allocates a brand-new ArrayList before the next call even starts. Under the hood, filter is implemented (in kotlin.collections.CollectionsKt) as roughly val result = ArrayList<T>(); for (e in this) if (predicate(e)) result.add(e); return result — a real loop, a real allocation, every time. Chain four operations over a million-element list and you've built three intermediate lists you never otherwise wanted, purely as scaffolding between steps.

asSequence() changes the underlying strategy entirely: it wraps the collection in a Sequence<T>, and each intermediate operation (.filter, .map) no longer executes anything — it just returns a new Sequence object that remembers the upstream sequence plus the transformation to apply, building up a lazy chain of wrapper objects. No actual work happens until a terminal operation (.toList(), .first(), .sum(), .forEach { }) pulls values through the chain one element at a time, applying every step to that one element before moving to the next. This is why sequences can short-circuit — sequence.filter {...}.first { predicate } can stop after finding the very first match instead of filtering the entire collection first — something a List-based chain fundamentally cannot do, since .filter on a List must finish before .first can even be called.

fold/reduce are implemented as simple accumulator loops with no allocation per step — fold(0.0) { acc, x -> acc + x } compiles to a for loop carrying one running Double variable, calling the lambda's generated invoke() on each iteration; reduce is identical except it seeds the accumulator from first() instead of a supplied initial value, which is exactly why it has nothing to seed with — and throws UnsupportedOperationException — on an empty collection.

Cheat sheet

Operation Purpose
map { } Transform each element
filter { } Keep elements matching a predicate
fold(initial) { acc, x -> } Combine into one value, safe on empty collections
reduce { acc, x -> } Combine into one value, using the first element as the start (throws on empty)
groupBy { } Bucket elements into a Map<Key, List<Element>>
associateBy { } Build a Map<Key, Element> (last one wins on duplicate keys)
partition { } Split into a Pair of (matching, non-matching) lists
flatMap { } Map then flatten one level of nesting
sortedBy { } / sortedWith(compareBy(...)) Sort by one or more keys
asSequence() Switch to lazy, single-pass evaluation for a chain

Exercise

Given a List<Order> where data class Order(val customer: String, val total: Double, val isPaid: Boolean), write code that: groups orders by customer using groupBy, then for each customer computes their total paid revenue using filter + fold (or sumOf), and finally prints customers sorted by revenue descending. Then rewrite the pipeline using asSequence() and confirm you get the same result.