Kotlin Collections Are Read-Only Until You Say Otherwise
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 blow up at runtime). Kotlin splits the idea in two at the type level: a read-only interface and a mutable one. The read-only kind is the default.
This post follows null safety. With nulls handled, collections are the next thing you’ll reach for constantly.
Creating collections
The factory functions are named for what they produce. The plain ones give you 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>. It has size, indexing, iteration — everything you need to read — but no add or remove. Those methods don’t exist 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.
to and map access
That mapOf call used to, which builds a key-value Pair. Map access uses bracket syntax and returns a nullable value, because the key might not be there:
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
This is the nuance that trips people up. List being read-only means you can’t mutate it through that reference — not that the underlying data can never change. A MutableList upcast to List is still 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
So List gives you a read-only interface, not an immutable value. It’s still a real improvement over Java — a function that takes a List can’t mutate your data, and that’s visible in its signature — but if you need a snapshot nobody can change, copy it with toList().
Let signatures say what they need
The payoff shows up in API design. Accept the read-only type and return the read-only type; 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 // returned as read-only List
}
A caller reading that signature knows report won’t modify the list they passed in. The mutability stays where it belongs — inside.
Arrays are a separate thing
Kotlin keeps Array<T> around mostly for Java interop and performance-sensitive code. It’s fixed-size and mutable in its elements, with its own builders (arrayOf, intArrayOf). For everyday work, reach for List; reach for Array when a Java API hands you one or demands one.
Reading a collection
Plenty of everyday questions need no lambdas at all — 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, which is the safer default when a collection might be empty.
Combining collections
The + and - operators produce a new read-only collection rather than mutating either side — exactly what you want when the original should stay put:
val more = numbers + 42 // [4, 8, 15, 16, 23, 42]
val fewer = numbers - 15 // [4, 8, 16, 23]
Maps in a bit more depth
When you’d rather have a fallback than a null, getOrDefault and getOrElse supply one without an Elvis dance:
val counts = mapOf("a" to 1, "b" to 2)
counts.getOrDefault("c", 0) // 0
counts.getOrElse("c") { 0 } // 0, computed only when missing
A mutable map supports bracket assignment, which is how you build one up:
val tally = mutableMapOf<String, Int>()
tally["a"] = 1 // put
tally["b"] = 2
Iterating a map walks its entries, each with a key and a value:
for (entry in ages) {
println("${entry.key} is ${entry.value}")
}
(There’s a tidier way to write that loop — pulling key and value apart in the header — but it relies on destructuring, a feature with its own post.)
A worked example
Counting words shows the read-only/mutable split in miniature: a mutable map 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 data your functions hand around stays still.
What I’ve skipped here is the good part — transforming collections with map, filter, fold, and the rest. That’s because those take functions as arguments, and functions-as-values is a topic of its own. Next: lambdas, where the collection API finally comes alive.
Practice: reinforce this with the companion workbook — short, click-to-reveal problems.