List, Set, Map
ordered ยท unique ยท key-valueKotlin'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.
// 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
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
List vs MutableList ยท not the same as immutableKotlin 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
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
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.[]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
listOf ยท mutableListOf ยท buildListCollections 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.
// 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]
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
for-in ยท forEach ยท withIndexEvery 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.
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
Map.Entry ยท Pair ยท componentNDestructuring โ 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).
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
map ยท filter ยท fold ยท sortedByKotlin'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.
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> ยท IntArray ยท fixed sizeArray<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.
// 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
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).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
Syntax cheat-sheet| Concept | Syntax | Notes |
|---|---|---|
| Read-only list | listOf(1, 2, 3) | No add/remove; may still change via another reference |
| Mutable list | mutableListOf(1, 2, 3) | add/remove/set available |
| Read-only set | setOf("a", "b") | Duplicates collapsed at construction |
| Read-only map | mapOf("k" to "v") | Built from Pair entries via `to` |
| Missing key | map["missing"] | Returns null, doesn't throw |
| Defensive copy | list.toList() | Independent snapshot, immune to source mutation |
| Imperative build | buildList { add(x) } | Returns a read-only result |
| Indexed iteration | for ((i, v) in list.withIndex()) | Avoids manual counters |
| Map iteration | for ((k, v) in map) | Destructures Map.Entry |
| Destructure a Pair | val (a, b) = pair | Needs component1()/component2() |
| Fixed-size array | arrayOf(1, 2, 3) | Mutable in place, can't grow/shrink |
| Primitive array | intArrayOf(1, 2, 3) | Backed by int[], avoids boxing |
| Filter + transform | list.filter { }.map { } | See Collections API guide for the full set |