Kotlin Collections & Sequences — Read-Only, Mutable, and Lazy
Why Kotlin splits collections into read-only and mutable interfaces, the rich operator library that replaces hand-written loops, and when to reach for a lazy Sequence instead of an eager List to avoid building throwaway intermediate collections.
Read-only and mutable, as separate types
Java has one List interface, and it can do everything — read, add, remove, clear. If you hand someone your list, they can mutate it behind your back. Kotlin splits the idea in two at the type level. List<T> is read-only: it has size, get, and iteration, but no add or remove. MutableList<T> extends it and adds the modifying operations.
The benefit is intent. When a function takes a List<T>, it is promising not to modify it; when it returns one, it is handing you something you are not meant to change. You expose the read-only type at API boundaries and keep the mutable type private.
An important honesty point. Read-only is not the same as immutable. A List is a read-only view — the object behind it might still be a MutableList that other code can change, and the underlying elements can themselves be mutable. Kotlin's read-only interfaces stop you from mutating through that reference; they do not freeze the data. For true immutability you reach for the kotlinx.collections.immutable library or defensive copies. Understanding this distinction prevents a subtle class of bugs where a "read-only" list changes underneath you.
Creating collections
Kotlin provides factory functions that read like literals. The read-only builders are the ones you reach for by default; the mutable variants give you something you can modify.
val nums = listOf(1, 2, 3) // List<Int> — read-only
val editable = mutableListOf(1, 2, 3) // MutableList<Int>
val unique = setOf("a", "b", "a") // Set — duplicates dropped → [a, b]
val ages = mapOf("Ann" to 30, "Bo" to 25) // Map, built with 'to' pairs
val empty = emptyList<String>() // shared, allocation-free empty list
Notice "Ann" to 30 — that is the to infix function from the Functions episode, creating a Pair. There is no special map syntax; it is just functions composing.
For collections built up with some logic, the
build functions give you a temporary mutable scope that returns a read-only result — the best of both worlds:
val items = buildList {
add("first")
if (includeMore) addAll(extras)
} // 'items' is a read-only List, built mutably inside
Under the hood these create the familiar JVM collections (ArrayList, LinkedHashSet, LinkedHashMap), so they interoperate seamlessly with Java code — another payoff of Kotlin's interop-first design.
The operator library: loops you never write
The reason idiomatic Kotlin rarely contains a manual for loop is the standard library's vast set of collection operators — all of them extension functions (from the Functions episode) on Iterable. You describe what you want, not the mechanics of looping.
data class Person(val name: String, val age: Int, val city: String)
people
.filter { it.age >= 18 } // keep matching
.map { it.name } // transform each
.sorted() // order
people.groupBy { it.city } // Map<City, List<Person>>
people.associateBy { it.name } // Map<Name, Person>
people.partition { it.age >= 18 } // Pair(adults, minors)
people.sumOf { it.age } // Int
people.maxByOrNull { it.age } // Person? (null if empty)
A few families are worth committing to memory: map/filter/flatMap for transforming, fold/reduce for accumulating a single result, groupBy/associate/partition for restructuring, and the aggregate shortcuts sumOf/count/any/all/none.
One naming convention pays off everywhere: operations that might find nothing come in two flavours.
first() and max() throw if the collection is empty or no element matches; firstOrNull() and maxOrNull() return null instead. The ...OrNull suffix is your cue to handle the empty case with the null-safety tools from the foundations episode, rather than risk an exception.
Eager Lists vs lazy Sequences
Those operators are wonderfully readable, but they hide a cost. On a regular collection, each operator is eager: it runs to completion and builds a brand-new intermediate list before the next operator starts. A chain of filter then map over a million items allocates a million-element list just to throw it away.
A Sequence changes the execution model from eager to lazy. Operators on a sequence do not run immediately — they build a pipeline. Nothing happens until a terminal operation (like toList, first, sum) pulls values through. Then each element flows through the entire chain one at a time, and no intermediate collections are created.
val firstFive = people.asSequence()
.filter { it.age >= 18 }
.map { it.name }
.take(5) // pipeline built, nothing run yet
.toList() // terminal op — now it runs, stopping after 5
Because it is lazy, the sequence above stops the moment it has five names — it never examines the rest of the list. Sequences also make infinite data possible: generateSequence(1) { it + 1 } is an endless stream of integers you can safely take from.
When to use which. For small collections and short chains, plain eager operators are simpler and often faster — the laziness has its own per-element overhead. Reach for
asSequence() when the collection is large, the chain of operations is long, or you only need part of the result (a find or take that can short-circuit). It is the same eager-vs-lazy trade-off found across many languages — and the sibling, in spirit, of Kotlin's asynchronous Flow from the Concurrency episode.
Working with maps
Maps get their own ergonomic touches. You read with the indexing operator, and iterating gives you entries you can destructure (using the componentN mechanism from the Object Model episode):
val ages = mapOf("Ann" to 30, "Bo" to 25)
val a = ages["Ann"] // 30, but the type is Int? — key might be absent
val safe = ages["Cy"] ?: 0 // Elvis for a default
for ((name, age) in ages) { // destructure each entry
println("$name is $age")
}
Indexing a map returns a nullable value, because the key may not be present — the type system surfacing the same null-safety discipline from the foundations episode. For mutable maps, getOrPut is a beloved idiom: return the existing value, or compute, store, and return a new one in a single call.
val cache = mutableMapOf<String, Int>()
fun lengthOf(s: String) = cache.getOrPut(s) { s.length } // memoise
Transforming maps mirrors the list operators: mapValues, mapKeys, filterKeys, filterValues, and the bridge associate/associateWith that turn a list into a map. Once you internalise that maps, sets, and lists all share the same operator vocabulary, restructuring data becomes a matter of picking the right verb.
AI prompts — collections and sequences
Use these prompts with an AI assistant to go deeper on the ideas in this episode.
Prompt 1 — Replace loops with operators:
Rewrite this imperative loop using Kotlin collection operators
(map, filter, groupBy, fold, etc.):
[paste loop]
Keep it readable and explain which operator replaces which part.
Prompt 2 — List or Sequence:
For this data pipeline, should I use eager list operators or
asSequence()?
[paste the chain, note the collection size and whether it short-circuits]
Explain the intermediate-allocation and short-circuit trade-offs.
Prompt 3 — Read-only API design:
Review this class's collection fields and method signatures:
[paste code]
Suggest where to expose read-only List/Set/Map vs MutableXxx, and
flag any place a 'read-only' collection could still be mutated elsewhere.
Prompt 4 — Map idioms:
I'm doing [grouping / counting / indexing / memoising] with a map:
[paste code]
Show the idiomatic Kotlin using groupBy, associateBy, getOrPut,
or mapValues as appropriate.
Kotlin Collections & Sequences — Quiz
7 questions
Seven questions on read-only vs mutable collections, the operator library, and eager Lists versus lazy Sequences.