Collections

List, Set, Map, the read-only vs mutable interface split, construction, iteration, and Array vs List.

List / Set / Map Read-only vs mutable Construction Iteration Destructuring Array vs List
๐Ÿ“š

List, Set, Map

Kotlin's collections build directly on Java's java.util collection classes underneath, but wrap them in a Kotlin-native API and type hierarchy. The three shapes answer three different questions about your data: List for "an ordered sequence, duplicates allowed," Set for "a group with no duplicates, membership is what matters," and Map for "a lookup from key to value." Choosing the right shape up front usually eliminates a whole category of bugs that come from bolting uniqueness or ordering logic onto the wrong structure.

The three shapes, side by side List / Set / Map
// List: ordered, indexed, duplicates allowed
val names: List<String> = listOf("Ada", "Grace", "Ada")
println(names[0])       // Ada
println(names.size)     // 3: duplicates counted

// Set: unordered by contract (LinkedHashSet preserves insertion order
// in practice), duplicates automatically collapsed
val uniqueNames: Set<String> = setOf("Ada", "Grace", "Ada")
println(uniqueNames.size) // 2: "Ada" only counted once
println("Ada" in uniqueNames) // true

// Map: key โ†’ value lookup, keys unique
val ages: Map<String, Int> = mapOf("Ada" to 36, "Grace" to 85)
println(ages["Ada"])   // 36
println(ages["Nobody"]) // null: missing key, not an exception
๐Ÿน
Coming from Go: Go has exactly one built-in collection type doing double duty โ€” slice for ordered sequences, map for key-value lookup โ€” with no built-in set type (the idiomatic workaround is map[T]struct{} or map[T]bool). Kotlin's Set is a first-class type with its own dedicated API rather than a map you're using for its keys alone.
๐Ÿ”

Read-Only vs Mutable Interfaces

Kotlin splits every collection type into a read-only interface (List, Set, Map) with no mutating members at all, and a mutable sub-interface (MutableList, MutableSet, MutableMap) that adds add, remove, and friends. This split exists purely in Kotlin's type system โ€” underneath, both usually point at the very same java.util.ArrayList at runtime, which means read-only is a compile-time view, not a runtime guarantee.

  val numbers: MutableList<Int> = mutableListOf(1, 2, 3)
  val view: List<Int> = numbers        // same underlying object, narrower view

  numbers.add(4)     // fine: numbers is typed as MutableList
  // view.add(4)      // ERROR: List has no add() โ€” compile-time only

  println(view)       // [1, 2, 3, 4]  โ† the mutation through `numbers`
                       //                 is visible through `view` too:
                       //                 it was never actually immutable
Read-only is a view, not a copy List vs MutableList
val mutable: MutableList<Int> = mutableListOf(1, 2, 3)

// readOnlyView is the SAME object underneath, just a narrower type
val readOnlyView: List<Int> = mutable

mutable.add(4)
println(readOnlyView) // [1, 2, 3, 4] โ€” the "read-only" view saw the mutation

// If another part of the code holds a mutable reference,
// a "read-only" collection can still change out from under you
โš ๏ธ
"Read-only" is not "immutable." A List reference guarantees only that you can't call a mutating method through that reference โ€” it says nothing about whether some other reference to the same underlying object can. If you genuinely need immutability (a guarantee nothing, anywhere, ever mutates the collection), copy it defensively with .toList(), which allocates a real, independent copy. See Gotchas for the failure mode this causes in real code.
๐Ÿน
Coming from Go: Go has nothing like this split โ€” a []int parameter is always mutable through the caller's backing array (though not resizable in place), and there's no type-level way to express "the callee can look but not touch." Kotlin's read-only interfaces are a compile-time-only API contract layered on top of the same mutability Go slices always have.
๐Ÿ—

Construction

Collections are built with top-level factory functions rather than constructors โ€” there's no List(...) you call with new. The naming convention is consistent across all three shapes: xOf(...) for a fixed set of elements, mutableXOf(...) for a mutable starting point, and buildX { ... } when you need to construct a collection with more complex logic than a flat argument list allows.

Factory functions *Of / build*
// Read-only construction
val a = listOf(1, 2, 3)
val b = setOf("x", "y")
val c = mapOf(1 to "one", 2 to "two")
val empty = emptyList<String>()  // explicit type needed: nothing to infer from

// Mutable construction
val md = mutableListOf(1, 2, 3)
val ms = mutableSetOf("x")
val mm = mutableMapOf("a" to 1)

// buildList / buildMap / buildSet: imperative construction,
// returning a read-only result โ€” useful when the logic doesn't
// fit neatly into a flat listOf(...) call
val computed = buildList {
    add(1)
    if (true) add(2)
    addAll(listOf(3, 4))
}
println(computed) // [1, 2, 3, 4]
๐Ÿ’ก
Default to the read-only factories (listOf, setOf, mapOf) for the same reason you default to val over var: most collections, once built, are never mutated again, and the narrower type documents that at the declaration site.
๐Ÿ”„

Iteration

Every collection type implements Iterable<T>, which is exactly what makes for (x in collection) work โ€” the same mechanism covered in Basics, extended here to each shape's own iteration semantics: a List yields elements in order, a Set yields each unique element once, and a Map yields Map.Entry objects you typically destructure into key and value.

Iterating each shape for-in
val names = listOf("Ada", "Grace")
for (name in names) println(name)

val ages = mapOf("Ada" to 36, "Grace" to 85)
for ((name, age) in ages) {
    println("$name is $age")
}

// forEach: the functional-style equivalent, as a trailing lambda
names.forEach { println(it) }
ages.forEach { (name, age) -> println("$name is $age") }

// withIndex(): index alongside element, without manual counters
for ((i, name) in names.withIndex()) {
    println("$i: $name")
}
โš ๏ธ
forEach is an inline function, so a bare return inside its lambda performs a non-local return from the enclosing function, not just a skip to the next element โ€” this surprises developers reaching for it like a continue. See Gotchas for the full breakdown and the return@forEach fix.
๐Ÿงฉ

Destructuring

Destructuring โ€” binding several names at once from a single value โ€” works on anything exposing component1(), component2(), and so on, which is exactly how Pair, Triple, and Map.Entry are already set up in the standard library, and exactly what data class generates for your own types (see Classes & Objects).

Destructuring in collection contexts component1/2
val pair: Pair<String, Int> = "Ada" to 36
val (name, age) = pair
println("$name is $age") // Ada is 36

// The most common place you'll see it: map iteration and map()/filter() lambdas
val scores = mapOf("Ada" to 95, "Grace" to 88)
val summaries = scores.map { (name, score) -> "$name: $score" }
println(summaries) // [Ada: 95, Grace: 88]

// Skip a component you don't need with _
val (first, _, third) = Triple(1, 2, 3)
println("$first, $third") // 1, 3
๐Ÿ› 

Common Operations: a Preview

Kotlin's collection interfaces come with an extensive set of extension functions for transforming, filtering, and aggregating โ€” enough that most loops you'd write by hand in other languages have a one-line equivalent here. This page only previews the shape of the API; the Collections API guide is the full reference with every operation grouped by what it does.

A taste of the API Preview
val numbers = listOf(1, 2, 3, 4, 5, 6)

val evenSquares = numbers
    .filter { it % 2 == 0 }   // [2, 4, 6]
    .map { it * it }            // [4, 16, 36]

val total = numbers.fold(0) { acc, n -> acc + n }  // 21
val sorted = numbers.sortedByDescending { it }        // [6, 5, 4, 3, 2, 1]

println(evenSquares)
println(total)
println(sorted)
๐Ÿงฑ

Array vs List

Array<T> is Kotlin's direct mapping to the JVM's native array type โ€” fixed-size, mutable in place, and the only option where Java interop or raw performance demands it. In everyday application code, List and MutableList are almost always the better default: Array has no read-only variant, no resizing, and โ€” because of type erasure and how the JVM represents arrays โ€” is invariant in a way that occasionally surprises.

When you'd reach for Array Array<T>
// Fixed size at creation; no add/remove, only index assignment
val arr = arrayOf(1, 2, 3)
arr[0] = 99
println(arr.joinToString())  // 99, 2, 3

// Primitive specializations avoid boxing every element
// (see Performance for why this matters)
val ints: IntArray = intArrayOf(1, 2, 3)  // backed by int[], not Integer[]

// varargs surface as Array under the hood โ€” the main place
// you'll actually touch Array without asking for it directly
fun sum(vararg nums: Int): Int = nums.sum()  // nums: IntArray
๐Ÿ’ก
Default to List/MutableList for application-level code โ€” it has the richer API, a read-only view option, and resizing built in. Reach for Array only for vararg parameters, interfacing with a Java API that specifically requires an array, or a proven hot path where avoiding boxed elements matters (measure before optimizing here).
๐Ÿน
Coming from Go: this maps closely to Go's own array vs slice split โ€” a fixed-size [3]int vs a growable []int. Kotlin's Array<T> is the fixed-size one; List/MutableList plays the role Go's slice plays as the default everyday choice.
๐Ÿ“‹

Quick Reference

Concept Syntax Notes
Read-only listlistOf(1, 2, 3)No add/remove; may still change via another reference
Mutable listmutableListOf(1, 2, 3)add/remove/set available
Read-only setsetOf("a", "b")Duplicates collapsed at construction
Read-only mapmapOf("k" to "v")Built from Pair entries via `to`
Missing keymap["missing"]Returns null, doesn't throw
Defensive copylist.toList()Independent snapshot, immune to source mutation
Imperative buildbuildList { add(x) }Returns a read-only result
Indexed iterationfor ((i, v) in list.withIndex())Avoids manual counters
Map iterationfor ((k, v) in map)Destructures Map.Entry
Destructure a Pairval (a, b) = pairNeeds component1()/component2()
Fixed-size arrayarrayOf(1, 2, 3)Mutable in place, can't grow/shrink
Primitive arrayintArrayOf(1, 2, 3)Backed by int[], avoids boxing
Filter + transformlist.filter { }.map { }See Collections API guide for the full set