Chaining Collection Operations

When you work with lists, sets, and maps in Kotlin, you rarely need just one operation on them. You usually need to filter out unwanted items, transform the rest, sort them, and pick a few. Kotlin lets you chain these collection operations one call after another, reading almost like a sentence: “take these people, keep the ones from NYC, sort by age, get their names, take the first two.” This chaining style, borrowed from functional programming, is one of the most productive and readable parts of everyday Kotlin.

Overview / How it works

Functions like filter, map, sortedBy, and take are all extension functions defined on Iterable<T> (the interface every List, Set, and similar collection implements). Each one takes the collection it’s called on, does its work, and returns a brand-new collection. Because the return value is itself a collection, you can immediately call another collection function on it with the dot operator, and the compiler checks each step’s element type against the next step’s expectations. That’s the entire trick behind chaining: it’s just ordinary method calls, one feeding into the next, with no special chaining syntax.

By default, these operations are eager. When you call .filter { ... } on a List, Kotlin walks the whole list right then and allocates a brand-new List holding only the matches. If you chain .map { ... } after it, that walks the filtered list and allocates yet another new list. A chain of five operations over a list of a million elements can, in the worst case, allocate five intermediate lists of up to a million elements each before you see a single final result.

Kotlin also offers a lazy alternative: call .asSequence() first to convert your collection into a Sequence<T>. On a sequence, filter and map don’t run immediately or build intermediate collections at all — they just record what should happen. Nothing actually executes until you call a terminal operation like .toList(), .first(), .sum(), or .count(). At that point, each element flows through the entire chain one at a time: element 1 is mapped, then filtered, then (if it passes) handed to the terminal operation; only then does element 2 start. This element-by-element evaluation means a sequence can stop early — useful with first() or take(n) — and never materializes full intermediate lists.

Because chained calls change the element type as they go (a List<Person> can become a List<String> after .map { it.name }), the compiler tracks the type through every link in the chain. If a later call in the chain expects something the previous step doesn’t produce — for example, calling .length on what is still a nullable type — the whole file fails to compile. This is a feature: type errors in a long chain are caught before you ever run the program.

Syntax

There’s no special chain syntax — it’s a sequence of method calls, each on the result of the previous one, usually written one per line for readability:

collection
    .filter { element -> /* condition */ }
    .map { element -> /* transform */ }
    .sortedBy { element -> /* sort key */ }
    .take(n)
Function What it does Returns
filter { predicate } Keeps elements where the predicate is true, preserves order List<T>
map { transform } Applies a transform to every element List<R>
sortedBy { selector } / sortedByDescending { selector } Sorts by a comparable key derived from each element List<T>
take(n) / drop(n) Keeps or discards the first n elements List<T>
distinct() Removes duplicate elements (by equals) List<T>
groupBy { selector } Buckets elements by a key Map<K, List<T>>
asSequence() Switches the rest of the chain to lazy, element-by-element evaluation Sequence<T>

Examples

Example 1: A simple numeric pipeline

fun main() {
    val numbers = listOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
    val result = numbers
        .filter { it % 2 == 0 }
        .map { it * it }
        .sorted()
    println(result)
}
Output:
[4, 16, 36, 64, 100]

The chain first keeps only even numbers (2, 4, 6, 8, 10), then squares each survivor (4, 16, 36, 64, 100), then sorts the result ascending. Because the squared values were already in ascending order, sorted() doesn’t visibly change anything here — but it’s good practice to include it explicitly rather than rely on incidental ordering.

Example 2: Filtering, sorting, and mapping a data class

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

fun main() {
    val people = listOf(
        Person("Alice", 30, "NYC"),
        Person("Bob", 25, "LA"),
        Person("Carol", 35, "NYC"),
        Person("Dave", 28, "LA"),
        Person("Eve", 40, "NYC")
    )

    val result = people
        .filter { it.city == "NYC" }
        .sortedBy { it.age }
        .map { it.name }
        .take(2)

    println(result)
}
Output:
[Alice, Carol]

Person is a data class, so it automatically gets a readable toString(), structural equals()/hashCode(), and a copy() function — useful elsewhere, though this chain only relies on its properties. The pipeline keeps the three NYC residents (Alice 30, Carol 35, Eve 40), sorts them by age (already ascending here), maps each Person down to just their name (note that it refers to a Person before map and a String after it), and finally takes the first two names.

Example 3: Eager lists vs. lazy sequences

fun main() {
    val numbers = listOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)

    println("Eager (List) chain:")
    val eagerResult = numbers
        .map {
            println("  mapping $it")
            it * it
        }
        .filter { it > 20 }
        .first()
    println("Eager result: $eagerResult")

    println("Lazy (Sequence) chain:")
    val lazyResult = numbers
        .asSequence()
        .map {
            println("  mapping $it")
            it * it
        }
        .filter { it > 20 }
        .first()
    println("Lazy result: $lazyResult")
}
Output:
Eager (List) chain:
  mapping 1
  mapping 2
  mapping 3
  mapping 4
  mapping 5
  mapping 6
  mapping 7
  mapping 8
  mapping 9
  mapping 10
Eager result: 25
Lazy (Sequence) chain:
  mapping 1
  mapping 2
  mapping 3
  mapping 4
  mapping 5
Lazy result: 25

Both chains land on the same answer, 25, but they get there very differently. The eager chain runs map over all ten numbers first, fully building a ten-element list, before filter and first() even start — hence ten “mapping” lines. The lazy chain, via asSequence(), processes one element at a time through the whole pipeline: as soon as element 5 maps to 25 and passes the > 20 filter, first() has its answer and evaluation stops — only five “mapping” lines print.

How it works step by step

Walking through the eager chain in Example 1: (1) numbers.filter { it % 2 == 0 } iterates the full ten-element list once and allocates a new five-element List<Int>. (2) .map { it * it } iterates that five-element list and allocates another new five-element List<Int> of squares. (3) .sorted() iterates that list once more and allocates a final sorted List<Int>, which is what gets assigned to result. Three passes over the data, three allocated lists — fine for ten elements, but worth knowing for large ones.

The lazy chain in Example 3 works differently: calling .asSequence() wraps the list in a Sequence object without copying anything. Calling .map and .filter on it just wraps that sequence in more sequence objects, still without touching any elements — these are intermediate operations. Only when the terminal operation .first() runs does anything actually happen: it pulls element 1 from the source, pushes it through map then filter, and if it fails the filter, pulls element 2, and so on, stopping the instant a match is found.

Common Mistakes

Mistake 1: Confusing sorted() with sort()

sorted() (and every filter/map-style function) returns a new collection and leaves the original untouched. Only sort(), available on MutableList, sorts in place. Calling sorted() and ignoring the result is a no-op on the original list:

fun main() {
    val numbers = mutableListOf(5, 3, 1, 4, 2)
    numbers.sorted()
    println(numbers)
}
Output:
[5, 3, 1, 4, 2]

The list is unchanged because sorted() built and discarded a new sorted list. Either capture that new list, or call the in-place sort() if you actually want to mutate the original MutableList:

fun main() {
    val numbers = mutableListOf(5, 3, 1, 4, 2)
    numbers.sort()
    println(numbers)
}
Output:
[1, 2, 3, 4, 5]

Mistake 2: Assuming filter { it != null } changes the type

Filtering out nulls at runtime doesn’t tell the compiler the resulting list’s element type is now non-null — it’s still List<String?>, so chaining a non-null operation right after fails to compile:

val names: List<String?> = listOf("Alice", null, "Bob")
val lengths: List<Int> = names
    .filter { it != null }
    .map { it.length }
println(lengths)
Compiler error:
Only safe (?.) or non-null asserted (!!.) calls are allowed on a
nullable receiver of type String?, because filter { it != null }
does not change the compiler-tracked element type from List<String?>.

The fix is filterNotNull(), a dedicated function that both removes nulls and narrows the compiler-tracked type down to List<String>:

val names: List<String?> = listOf("Alice", null, "Bob")
val lengths: List<Int> = names
    .filterNotNull()
    .map { it.length }
println(lengths)
Output:
[5, 3]

Mistake 3: Chaining many eager operations over a large collection

Each eager step in a long chain allocates a full intermediate list, even if you only need the first match. Over a large collection this wastes both time and memory:

val bigList = (1..1_000_000).toList()
val result = bigList
    .map { it * 2 }
    .filter { it % 3 == 0 }
    .first()
println(result)
Output:
6

This gets the right answer, but it builds two full million-element lists (the mapped one and the filtered one) just to read the very first element. Switching to asSequence() gives the identical result while only doing as much work as needed to find it:

val bigList = (1..1_000_000).toList()
val result = bigList.asSequence()
    .map { it * 2 }
    .filter { it % 3 == 0 }
    .first()
println(result)
Output:
6

The output is identical, but the sequence version stops mapping and filtering after just a few elements instead of processing a million of them twice.

Best Practices

  • Format long chains with one call per line, starting each with a leading dot, so the pipeline reads top-to-bottom as a sequence of steps.
  • Reach for asSequence() when a chain has several intermediate steps and runs over a large collection, especially when it ends in a short-circuiting terminal like first(), find(), or any().
  • For small collections (a few hundred elements or fewer), plain eager chains are simpler and usually just as fast — don’t reach for asSequence() reflexively.
  • Use filterNotNull() instead of filter { it != null } whenever you need the compiler to actually narrow a nullable element type.
  • Prefer a single well-named intermediate val to break up a chain that’s grown too long or hard to read in one expression, rather than cramming everything onto one line.
  • Remember that almost every collection function returns a new collection — assign the result (or return it) instead of expecting the original variable to change.

Practice Exercises

  • Given val words = listOf("kotlin", "is", "fun", "and", "concise"), chain operations to keep only words with more than 2 letters, convert them to uppercase, and sort them alphabetically. Expected output: [CONCISE, FUN, KOTLIN].
  • Given a data class Product(val name: String, val price: Double, val inStock: Boolean) and a list of several products, chain filter, sortedByDescending, and map to produce a list of the names of the three most expensive in-stock products.
  • Rewrite exercise 2’s chain to use asSequence(), and add a println inside the map lambda to confirm for yourself, by counting the printed lines, that fewer elements are processed than in the eager version once you swap take(3) for a terminal like first().

Summary

  • Chaining collection operations is just calling one extension function after another; each call returns a new collection that becomes the receiver for the next call.
  • By default, list operations are eager: every intermediate step in the chain allocates and fully populates a new collection before the next step runs.
  • asSequence() switches to lazy, element-by-element evaluation; nothing runs until a terminal operation like toList(), first(), or sum() is called.
  • sorted()/filter()/map() return new collections and never mutate the original; only MutableList functions like sort() mutate in place.
  • filter { it != null } does not narrow a nullable type for the compiler — use filterNotNull() when you need a genuinely non-null element type downstream.
  • Prefer sequences for long chains over large collections, especially when a short-circuiting terminal operation means most elements never need to be touched.