Kotlin Crash Course
Chapter 4

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
Read-only ≠ immutable:

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
Practical use case: Lists are ideal for ordered data like a playlist, a timeline of messages, or search results from an API where duplicates are allowed and order matters.

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}
Practical use case: Use sets to track unique tags on a blog post, check for overlapping interests between users, or quickly test membership (e.g., "is this user ID in the banned set?").

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()
Practical use case: Maps power lookup tables such as user settings by key, caching API responses by URL, or converting country codes to display names with O(1) lookup time.

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]
Practical use case: In an e-commerce app, chain 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]}
Practical use case: Use 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

JavaScript
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);
Python
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]
Practical use cases: Use 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()
Practical use case: Reading a CSV file often gives you separate lists for each column. 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}
Practical use cases: Use 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()
Practical use case: A company has departments with employee lists; use 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
Practical use cases: Use 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")
}
Practical use case: Collection builders shine when the final collection depends on runtime conditions. For example, building an API request body where some fields are optional, or constructing a UI settings map that varies by user permissions.

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...UseFactory
Ordered duplicatesListlistOf()
Unique elementsSetsetOf()
Key-value pairsMapmapOf()
Fast lookup by keySet or MaphashSetOf(), hashMapOf()
Maintain insertion orderLinkedHashSetlinkedSetOf()
Sorted orderTreeSetsortedSetOf()
// 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}

Try It Yourself

Given a list of numbers, write a single chained expression that:

  1. Filters out numbers ≤ 0
  2. Doubles each remaining number
  3. Takes only the first 3 results
  4. 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)
[2, 4, 6]
[1, 2, 3]
Compile error — you can't modify a read-only list