Kotlin Collections Are Read-Only Until You Say Otherwise
Lists, sets, and maps: the read-only versus mutable split and why it isn't runtime-enforced, the factory and builder functions, set algebra, map operations like getOrPut, primitive arrays, and how the split shapes your signatures.
Every collection in Java is mutable. List, Set, Map — all of them expose add and remove, and the only way to hand someone a list they can’t modify is to wrap it in Collections.unmodifiableList and hope they don’t call the mutating methods anyway (which compile fine and throw at runtime). Kotlin splits the idea in two at the type level: a read-only interface and a mutable one, with the read-only kind as the default. It’s a genuine improvement — and, as we’ll see, one with a sharp edge that catches people who assume “read-only” means “immutable.”
This chapter follows null safety; with nulls handled, collections are the next thing you reach for constantly. Everything here was run against Kotlin 2.4.10.
Creating collections
The factory functions are named for what they produce. The plain ones give read-only collections:
val numbers = listOf(1, 2, 3)
val unique = setOf("a", "b", "a") // {"a", "b"}
val ages = mapOf("Ada" to 36, "Linus" to 54)
numbers has type List<Int> — size, indexing, iteration, everything you need to read, but no add or remove. Those methods aren’t on the type. When you genuinely need to change a collection after creating it, ask for the mutable version:
val seen = mutableListOf<String>()
seen.add("first")
seen.add("second")
mutableListOf, mutableSetOf, mutableMapOf — same idea, with the mutating methods present. The type hierarchy behind this is worth one sentence: MutableList<T> extends List<T>, so a MutableList is-a List. That subtyping is exactly what makes the sharp edge below possible.
to, and map access
That mapOf used to, an infix function that builds a key-value Pair. Map access uses bracket syntax and returns a nullable value, because the key might be absent:
val age: Int? = ages["Ada"] // 36
val missing: Int? = ages["Nobody"] // null
The nullable return is the type system being honest — a lookup that might miss should make you handle the miss, and here it does.
Read-only is a view, not a guarantee — and not runtime-enforced
This is the nuance that trips people, and it has two layers. The first: List being read-only means you can’t mutate through that reference — not that the underlying data can’t change. A MutableList upcast to List is the same object underneath:
val mutable = mutableListOf(1, 2, 3)
val readOnly: List<Int> = mutable // same object, read-only view
mutable.add(4)
println(readOnly) // [1, 2, 3, 4] — it changed
The second layer is sharper, and it surprised me enough to run it twice. The read-only interface is a compile-time fiction — there is no separate immutable data structure underneath, and nothing stops a determined caller from casting the view back to MutableList and mutating it:
val view: List<Int> = mutableListOf(1, 2, 3)
(view as MutableList<Int>).add(99)
println(view) // [1, 2, 3, 99]
That cast compiles and runs — no UnsupportedOperationException — because the object really is a mutable list wearing a read-only type. (It only works when the underlying object is actually mutable; try it on the result of listOf(...), whose runtime type is java.util.Arrays.ArrayList, and the add throws.) The takeaway: List documents intent and stops honest mistakes, but it is not a security boundary. If you need a snapshot that genuinely cannot change out from under you, copy it with toList(), which allocates a fresh backing store the caller’s reference can’t reach.
Let signatures say what they need
The payoff is in API design. Accept the read-only type, return the read-only type, and reach for the mutable one only inside a function as a local detail:
fun report(scores: List<Int>): List<String> {
val lines = mutableListOf<String>() // mutable while building
for (score in scores) lines.add("score: $score")
return lines // exposed as read-only List
}
A caller reading that signature knows report won’t modify the list they passed. The mutability stays where it belongs — inside.
Builders: mutable while constructing, read-only after
When building a collection needs a few statements — conditionals, loops — the builder functions give you a mutable receiver inside the block and hand back a read-only result:
val list = buildList {
add(1)
addAll(listOf(2, 3))
if (includeFour) add(4)
} // type is List<Int>, but you built it with add()
buildList, buildSet, and buildMap are the clean way to express “construct this imperatively, then freeze the handle.” Inside the block you have the full mutable API; the returned type is read-only, so the mutation is scoped to construction. It’s the report pattern above, packaged.
Reading a collection
Plenty of everyday questions need no lambdas — they’re plain members:
val numbers = listOf(4, 8, 15, 16, 23)
numbers.size // 5
numbers.isEmpty() // false
numbers.first() // 4
numbers.last() // 23
numbers[2] // 15
23 in numbers // true — the in operator again
first() and last() throw on an empty list; the firstOrNull() / lastOrNull() variants return a nullable instead, the safer default when a collection might be empty. getOrNull(index) does the same for out-of-bounds indexing, where [ ] would throw.
Combining collections and set algebra
The + and - operators produce a new read-only collection rather than mutating either side:
val more = numbers + 42 // [4, 8, 15, 16, 23, 42]
val fewer = numbers - 15 // [4, 8, 16, 23]
Sets add the algebra you’d expect, each returning a new set:
val a = setOf(1, 2, 3)
val b = setOf(3, 4)
a union b // [1, 2, 3, 4]
a intersect b // [3]
a subtract b // [1, 2]
These are the readable way to express membership math that would otherwise be a loop and a temporary set.
Maps in more depth
When you’d rather have a fallback than a null, getOrDefault and getOrElse supply one without an Elvis dance — and on a mutable map, getOrPut computes-and-stores a value only when the key is missing, which is the one-liner behind almost every in-memory cache:
val counts = mapOf("a" to 1, "b" to 2)
counts.getOrDefault("c", 0) // 0
counts.getOrElse("c") { 0 } // 0, computed lazily only when missing
val cache = mutableMapOf<String, Int>()
cache.getOrPut("a") { expensiveCompute() } // computes, stores, returns
cache.getOrPut("a") { expensiveCompute() } // key present — lambda NOT called
A mutable map supports bracket assignment, and iterating a map walks its entries:
val tally = mutableMapOf<String, Int>()
tally["a"] = 1
for (entry in ages) {
println("${entry.key} is ${entry.value}")
}
(There’s a tidier loop header that pulls key and value apart, but it relies on destructuring, a feature with its own chapter.)
Arrays are a separate thing
Kotlin keeps Array<T> around mostly for Java interop and performance-sensitive code — fixed-size, mutable in its elements, with its own builders (arrayOf, intArrayOf). The distinction that matters, and a direct callback to the types chapter: Array<Int> is Integer[] (boxed), while IntArray is a primitive int[] (unboxed). For everyday work reach for List; reach for Array when a Java API hands you one or demands one, and for IntArray/LongArray/DoubleArray when you’re counting allocations in a hot path.
A worked example
Counting words shows the read-only/mutable split in miniature — mutable while building, handed back behind a read-only type so callers can’t disturb it:
fun wordCounts(words: List<String>): Map<String, Int> {
val counts = mutableMapOf<String, Int>()
for (word in words) {
counts[word] = counts.getOrDefault(word, 0) + 1
}
return counts // callers get a Map, not a MutableMap
}
Final thoughts
The read-only-by-default split is a small idea with a large effect: it makes “who’s allowed to change this?” a fact in the type signature instead of a comment you hope everyone reads. You opt into mutability deliberately, name it mutable, and keep it local. The one thing not to over-trust is the word “read-only” — it’s a compile-time contract, not an immutable data structure, so a cast can still reach the mutable object underneath. When you need a true snapshot, toList() it.
What I’ve skipped here is the good part — transforming collections with map, filter, fold, and the rest. Those take functions as arguments, and functions-as-values is a topic of its own. Next: lambdas, where functions become values you pass to a collection — and the API above finally comes alive.
Practice: reinforce this with the companion workbook — short, click-to-reveal problems.
Comments