Collections
Lists, sets, maps, and the functional operations that make Kotlin collections a joy to use. Read-only by default, powerful with lambdas.
By the end of this chapter, you will:
- Create and use read-only and mutable collections
- Master functional operations: map, filter, reduce, groupBy
- Understand when to use List vs Set vs Map
- Use collection destructuring and ranges
- Chain operations efficiently
Read-Only vs Mutable Collections
Kotlin distinguishes between read-only and mutable collections at the type level. By default, use read-only collections.
// Read-only interface — preferred
val names = listOf("Alice", "Bob", "Charlie")
// names.add("Dave") // ❌ Compile error — no add method
// Mutable — only when you need to change it
val mutableNames = mutableListOf("Alice", "Bob")
mutableNames.add("Charlie") // ✅ OK
// Same for sets and maps
val set = setOf(1, 2, 3) // read-only
val mutableSet = mutableSetOf(1, 2, 3) // mutable
val map = mapOf("a" to 1, "b" to 2) // read-only
val mutableMap = mutableMapOf("a" to 1) // mutable
listOf() returns a List interface with no mutation methods — but if someone holds a MutableList reference and casts it, the underlying data could change.For true immutability, always expose
List (not MutableList) in your public APIs. The "read-only" terminology is more precise than "immutable."
Creating Collections
Lists
val fruits = listOf("Apple", "Banana", "Cherry")
val empty = emptyList<String>()
// Accessing elements
val first = fruits[0] // "Apple" — zero-indexed
val firstSafe = fruits.firstOrNull() // "Apple" (null if empty)
val last = fruits.last() // "Cherry"
val count = fruits.size // 3
// Checking
val hasApple = "Apple" in fruits // true
val index = fruits.indexOf("Banana") // 1
Sets
val unique = setOf(1, 2, 2, 3) // {1, 2, 3} — duplicates removed
val hasTwo = 2 in unique // true
// Set operations
val a = setOf(1, 2, 3)
val b = setOf(2, 3, 4)
val union = a union b // {1, 2, 3, 4}
val intersect = a intersect b // {2, 3}
val diff = a subtract b // {1}
Maps
val scores = mapOf("Alice" to 95, "Bob" to 87)
// Accessing
val aliceScore = scores["Alice"] // 95 (returns Int?)
val aliceSafe = scores.getOrDefault("Alice", 0) // 95
val charlie = scores.getOrElse("Charlie") { 0 } // 0
// Iterating
for ((name, score) in scores) {
println("$name: $score")
}
// Destructuring
val (key, value) = scores.entries.first()
Functional Operations
This is where Kotlin collections shine. Every operation returns a new collection — no mutation.
map, filter, forEach
val numbers = listOf(1, 2, 3, 4, 5)
// Transform each element
val doubled = numbers.map { it * 2 } // [2, 4, 6, 8, 10]
// Keep only elements matching a condition
val evens = numbers.filter { it % 2 == 0 } // [2, 4]
// Iterate (returns Unit — no new collection)
numbers.forEach { println(it) }
// Chaining — read left to right like a sentence
val result = numbers
.filter { it > 2 }
.map { it * 10 }
.sortedDescending()
// [50, 40, 30]
products.filter { it.inStock }.map { it.displayPrice }.sorted() to show available items with formatted prices in ascending order.
reduce, fold
val numbers = listOf(1, 2, 3, 4)
// reduce: combine all elements (throws on empty list)
val sum = numbers.reduce { acc, n -> acc + n } // 10
// fold: same but with initial value (safe for empty lists)
val sumWithStart = numbers.fold(100) { acc, n -> acc + n } // 110
// Grouping
val words = listOf("apple", "ant", "banana", "blue", "cherry")
val byFirstLetter = words.groupBy { it.first() }
// {a=[apple, ant], b=[banana, blue], c=[cherry]}
fold to compute a shopping cart total with an initial discount, or reduce to find the highest-scoring player from a list of game results.
Compare with JS / Python
const nums = [1, 2, 3, 4, 5];
const doubled = nums.map(n => n * 2);
const evens = nums.filter(n => n % 2 === 0);
const sum = nums.reduce((a, b) => a + b, 0);
nums = [1, 2, 3, 4, 5]
doubled = [n * 2 for n in nums]
evens = [n for n in nums if n % 2 == 0]
from functools import reduce
sum_val = reduce(lambda a, b: a + b, nums, 0)
Kotlin difference: Very similar to JS array methods! But Kotlin operations work on read-only collections and are type-safe. No list comprehensions like Python, but chaining is more readable than nested comprehensions.
Chunked and Windowed
Break a collection into sublists for batch processing or sliding window analysis. These are invaluable for paginating UI lists or processing time-series data.
val numbers = listOf(1, 2, 3, 4, 5, 6, 7, 8)
// chunked: groups into sublists of given size
val pairs = numbers.chunked(2)
// [[1, 2], [3, 4], [5, 6], [7, 8]]
// windowed: sliding window of given size
val windows = numbers.windowed(3)
// [[1, 2, 3], [2, 3, 4], [3, 4, 5], [4, 5, 6], [5, 6, 7], [6, 7, 8]]
// windowed with step: skip elements between windows
val stepped = numbers.windowed(3, step = 2)
// [[1, 2, 3], [3, 4, 5], [5, 6, 7]]
// With transform
val sums = numbers.windowed(3) { it.sum() }
// [6, 9, 12, 15, 18, 21]
chunked to batch database writes (e.g., 100 rows at a time) or create grid layouts for a photo gallery. Use windowed for rolling averages in analytics, sliding text tokens in NLP, or detecting patterns in sensor data.
Zip and Unzip
Combine two parallel collections element by element, or split a list of pairs back into separate lists.
val names = listOf("Alice", "Bob", "Charlie")
val ages = listOf(25, 30, 35)
// zip: pairs elements from two lists
val pairs = names.zip(ages)
// [(Alice, 25), (Bob, 30), (Charlie, 35)]
// zip with transform
val descriptions = names.zip(ages) { name, age ->
"$name is $age years old"
}
// ["Alice is 25 years old", "Bob is 30 years old", "Charlie is 35 years old"]
// unzip: split list of pairs back into two lists
val (unzippedNames, unzippedAges) = pairs.unzip()
zip lets you combine them into records. Unzip a list of coordinate pairs into separate latitude and longitude lists for charting libraries.
Associate and GroupBy
Transform collections into maps for fast lookups, categorization, and counting.
data class Product(val id: Int, val name: String, val category: String)
val products = listOf(
Product(1, "Laptop", "Electronics"),
Product(2, "Shirt", "Clothing"),
Product(3, "Phone", "Electronics")
)
// associateBy: map by unique key
val byId = products.associateBy { it.id }
// {1=Product(1, Laptop, Electronics), ...}
// associate: custom key-value pairs
val nameToCategory = products.associate { it.name to it.category }
// {Laptop=Electronics, Shirt=Clothing, Phone=Electronics}
// associateWith: use elements as keys, compute values
val nameLengths = products.associateWith { it.name.length }
// {Product(1, Laptop, Electronics)=6, ...}
// groupBy: grouping into Map<K, List<T>>
val byCategory = products.groupBy { it.category }
// {Electronics=[Product(1, ...), Product(3, ...)], Clothing=[Product(2, ...)]}
// groupingBy + eachCount: counting occurrences
val words = listOf("apple", "banana", "apple", "cherry", "banana", "apple")
val counts = words.groupingBy { it }.eachCount()
// {apple=3, banana=2, cherry=1}
associateBy to build lookup tables from database rows for O(1) access by ID. Use groupBy to organize messages by sender in a chat app, or to build histograms from log entries.
Flatten and FlatMap
Work with nested collections by flattening them or mapping and flattening in one step.
val listOfLists = listOf(
listOf(1, 2, 3),
listOf(4, 5),
listOf(6, 7, 8)
)
// flatten: collapse one level of nesting
val flat = listOfLists.flatten()
// [1, 2, 3, 4, 5, 6, 7, 8]
// flatMap: map each element to a list, then flatten
data class Department(val name: String, val employees: List<String>)
val departments = listOf(
Department("Engineering", listOf("Alice", "Bob")),
Department("Design", listOf("Charlie", "Dana"))
)
val allEmployees = departments.flatMap { it.employees }
// ["Alice", "Bob", "Charlie", "Dana"]
// Equivalent to:
// departments.map { it.employees }.flatten()
flatMap to get a single list of all employees for a company-wide email. In a blog app, flatMap can gather all comments from a list of posts.
ArrayDeque
ArrayDeque is a double-ended queue that supports O(1) operations at both ends. It is the recommended Kotlin type for both stack (LIFO) and queue (FIFO) behavior.
val deque = ArrayDeque<String>()
// Queue (FIFO)
deque.addLast("Alice")
deque.addLast("Bob")
val first = deque.removeFirst() // "Alice"
// Stack (LIFO)
deque.addLast("Task 1")
deque.addLast("Task 2")
val last = deque.removeLast() // "Task 2"
// Peeking without removing
val peekFirst = deque.firstOrNull()
val peekLast = deque.lastOrNull()
// Initialize from a list
val history = ArrayDeque(listOf("Page 1", "Page 2"))
history.addLast("Page 3")
if (history.size > 10) history.removeFirst() // keep last 10 pages
ArrayDeque as a navigation history stack in an Android WebView, an undo buffer in a text editor, or a job queue for background workers. Prefer it over LinkedList — it is faster and uses less memory.
Collection Builders
When you need to build a collection with loops, conditions, or complex logic, use builder functions. They are more efficient than creating a mutable list and then wrapping it.
// buildList: create a List with imperative logic
val result = buildList {
add("Start")
addAll(listOf("A", "B", "C"))
if (System.currentTimeMillis() % 2 == 0L) {
add("Even")
}
repeat(3) { add("Item $it") }
}
// buildMap: create a Map with conditions
val config = buildMap<String, Any> {
put("debug", true)
put("version", 42)
if (BuildConfig.FLAVOR == "dev") {
put("mockApi", true)
}
}
// buildSet: create a Set, duplicates ignored automatically
val uniqueTags = buildSet {
add("kotlin")
addAll(postTags)
add("featured")
}
Ranges
// Closed range (includes both ends)
val oneToFive = 1..5 // 1, 2, 3, 4, 5
// Half-open (excludes end)
val oneToFour = 1 until 5 // 1, 2, 3, 4
// Downward
val fiveToOne = 5 downTo 1 // 5, 4, 3, 2, 1
// With step
val evens = 2..10 step 2 // 2, 4, 6, 8, 10
// In for loops
for (i in 1..5) { println(i) }
// Checking containment
val valid = 5 in 1..10 // true
// Range of characters
val alphabet = 'a'..'z'
Collection Type Hierarchy
| When you need... | Use | Factory |
|---|---|---|
| Ordered duplicates | List | listOf() |
| Unique elements | Set | setOf() |
| Key-value pairs | Map | mapOf() |
| Fast lookup by key | Set or Map | hashSetOf(), hashMapOf() |
| Maintain insertion order | LinkedHashSet | linkedSetOf() |
| Sorted order | TreeSet | sortedSetOf() |
// groupBy — organize items into a Map
val people = listOf("Alice" to 25, "Bob" to 30, "Carol" to 25)
val byAge = people.groupBy({ it.second }, { it.first })
// {25=[Alice, Carol], 30=[Bob]}
// partition — split into two lists by predicate
val (adults, minors) = people.partition { it.second >= 18 }
// associate — build a map from a list
val nameToAge = people.associate { it.first to it.second }
// {Alice=25, Bob=30, Carol=25}
📖 Official docs: Collections Overview (kotlinlang.org) · Collection Operations (kotlinlang.org)
Try It Yourself
Given a list of numbers, write a single chained expression that:
- Filters out numbers ≤ 0
- Doubles each remaining number
- Takes only the first 3 results
- Returns their sum
val numbers = listOf(-2, 5, -1, 3, 4, 10, -5, 8)
// Your expression:
val result =
println(result) // Expected: 24 (5*2 + 3*2 + 4*2 = 10 + 6 + 8)
Stuck? Show hint
numbers.filter { it > 0 }.map { it * 2 }.take(3).sum()
Concept Check
What does this code print?
val list = listOf(1, 2, 3)
list.map { it * 2 }
println(list)