Collections #

Almost no Kotlin code exists without touching collections. Product lists, user directories, database query results, API responses — everything boils down to the same data structures: List, Set, or Map. Kotlin doesn’t just inherit collections from Java — it rebuilds them with a far more expressive API, strict immutability at the type level, and dozens of built-in transformation functions that make code feel like describing the problem rather than solving it directly. This article covers the entire Kotlin Collections ecosystem: from the mutable vs immutable distinction, the most commonly used core operations, to function composition for processing complex data.

The Collection Hierarchy and Types #

Kotlin separates mutable and immutable collections at the type level — not just as a convention or wrapper like Java’s Collections.unmodifiableList(). This isn’t merely a feature; it’s a design decision that structurally prevents an entire class of bugs.

flowchart TD
    Iterable["Iterable<T>"] --> Collection["Collection<T>"]
    Collection --> List["List<T>\n(read-only)"]
    Collection --> Set["Set<T>\n(read-only)"]
    Collection --> MutableCollection["MutableCollection<T>"]
    MutableCollection --> MutableList["MutableList<T>"]
    MutableCollection --> MutableSet["MutableSet<T>"]
    Map["Map<K,V>\n(read-only)"] --> MutableMap["MutableMap<K,V>"]

The three main collection types in Kotlin:

TypeDescriptionDefault Implementation
List<T>Ordered elements, 0-based indexing, duplicates allowedArrayList
Set<T>Unique collection, no duplicates, no particular orderLinkedHashSet
Map<K, V>Key-value pairs, keys must be uniqueLinkedHashMap
// Read-only: cannot add, remove, or change
val angka: List<Int> = listOf(1, 2, 3, 4, 5)
val kota: Set<String> = setOf("Jakarta", "Bandung", "Surabaya")
val kode: Map<String, Int> = mapOf("IDN" to 62, "MYS" to 60, "SGP" to 65)

// Mutable: can be modified after creation
val angkaMutable: MutableList<Int> = mutableListOf(1, 2, 3)
val kotaMutable: MutableSet<String> = mutableSetOf("Jakarta", "Bandung")
val kodeMutable: MutableMap<String, Int> = mutableMapOf("IDN" to 62)

// ANTI-PATTERN: always using MutableList without a reason
// ✗ val produk: MutableList<Produk> = mutableListOf(...)

// CORRECT: default to read-only, mutable only when modification is needed
// ✓ val produk: List<Produk> = listOf(...)
listOf(), setOf(), and mapOf() return read-only implementations. This doesn’t mean their contents can’t change if the elements inside are mutable objects — it means references to elements inside the collection can’t be added or removed.

Creating Collections #

Kotlin provides several ways to create collections, each suited to different scenarios.

Constructor Functions #

// An empty list
val kosong: List<String> = emptyList()

// A list with one element
val tunggal: List<Int> = listOf(42)

// A list with initial elements
val buah = listOf("apel", "jeruk", "mangga", "durian")

// A list with a size and generator — very useful for dummy data
val kuadrat = List(5) { i -> i * i }          // [0, 1, 4, 9, 16]
val matriks = List(3) { baris -> List(3) { kolom -> baris * 3 + kolom } }

// Set
val prioritas = setOf("HIGH", "MEDIUM", "LOW")

// Map
val negara = mapOf(
    "ID" to "Indonesia",
    "MY" to "Malaysia",
    "SG" to "Singapura"
)

buildList, buildSet, buildMap #

buildList, buildSet, and buildMap are Kotlin’s idiomatic way to build collections with conditional logic, without creating a mutable collection and then converting it.

// ANTI-PATTERN: create a mutable, fill it, then assign to val
val menu = mutableListOf<String>()
menu.add("Nasi Goreng")
menu.add("Mie Ayam")
if (isWeekend) menu.add("Sate")
val menuFinal: List<String> = menu  // manual conversion

// CORRECT: use buildList for conditional logic
val menuFinal = buildList {
    add("Nasi Goreng")
    add("Mie Ayam")
    if (isWeekend) add("Sate")
}

// buildMap for a Map with logic
val config = buildMap {
    put("timeout", 30)
    put("retries", 3)
    if (isProduction) {
        put("cache_ttl", 3600)
        put("log_level", "ERROR")
    } else {
        put("log_level", "DEBUG")
    }
}

Transformation Operations #

This is the core of Kotlin Collections’ expressiveness. Instead of writing manual loops, you describe what transformation you want — and Kotlin handles it.

map — Transform Every Element #

map takes each element, applies a function, and produces a new List with the transformed results.

data class Produk(val nama: String, val harga: Double, val stok: Int)

val produk = listOf(
    Produk("Laptop", 15_000_000.0, 10),
    Produk("Mouse", 250_000.0, 50),
    Produk("Keyboard", 800_000.0, 30)
)

// Take only the names
val namaProduk: List<String> = produk.map { it.nama }
// ["Laptop", "Mouse", "Keyboard"]

// Calculate the price after a 10% discount
val hargaDiskon: List<Double> = produk.map { it.harga * 0.9 }
// [13_500_000.0, 225_000.0, 720_000.0]

// Transform to another type
data class ProdukRingkas(val nama: String, val hargaFormatted: String)

val ringkasan = produk.map { p ->
    ProdukRingkas(p.nama, "Rp ${"%,.0f".format(p.harga)}")
}

filter — Filter Based on a Condition #

filter produces a new List containing only the elements that satisfy a predicate.

// Products with stock > 20
val tersedia = produk.filter { it.stok > 20 }

// Expensive products (> 1 million)
val mahal = produk.filter { it.harga > 1_000_000.0 }

// filterNot: the opposite of filter
val murah = produk.filterNot { it.harga > 1_000_000.0 }

// filterIsInstance: filter by type
val campuran: List<Any> = listOf(1, "dua", 3.0, "empat", 5)
val hanyaString: List<String> = campuran.filterIsInstance<String>()
// ["dua", "empat"]

// filterNotNull: remove null elements from a nullable list
val denganNull: List<String?> = listOf("apel", null, "jeruk", null, "mangga")
val bersih: List<String> = denganNull.filterNotNull()
// ["apel", "jeruk", "mangga"]

flatMap — Flatten Nested Collections #

flatMap is useful when each element produces a list, and you want all results in one flat list.

data class Kategori(val nama: String, val produk: List<String>)

val katalog = listOf(
    Kategori("Elektronik", listOf("Laptop", "HP", "Tablet")),
    Kategori("Aksesoris", listOf("Mouse", "Keyboard", "Headset")),
    Kategori("Peripherals", listOf("Monitor", "Webcam"))
)

// ANTI-PATTERN: manual loop + addAll
val semuaProduk = mutableListOf<String>()
for (kategori in katalog) {
    semuaProduk.addAll(kategori.produk)
}

// CORRECT: flatMap
val semuaProduk = katalog.flatMap { it.produk }
// ["Laptop", "HP", "Tablet", "Mouse", "Keyboard", "Headset", "Monitor", "Webcam"]

// flatten: if you already have a List<List<T>>
val matriks = listOf(listOf(1, 2, 3), listOf(4, 5, 6), listOf(7, 8, 9))
val flat = matriks.flatten()
// [1, 2, 3, 4, 5, 6, 7, 8, 9]

Grouping Operations #

groupBy — Group by a Key #

groupBy produces a Map<K, List<V>> where each key is the result of a grouping function.

data class Transaksi(
    val id: Int,
    val kategori: String,
    val nominal: Double,
    val tanggal: String
)

val transaksi = listOf(
    Transaksi(1, "Makanan", 85_000.0, "2024-01"),
    Transaksi(2, "Transport", 45_000.0, "2024-01"),
    Transaksi(3, "Makanan", 120_000.0, "2024-02"),
    Transaksi(4, "Hiburan", 200_000.0, "2024-02"),
    Transaksi(5, "Transport", 35_000.0, "2024-02")
)

// Group by category
val perKategori: Map<String, List<Transaksi>> = transaksi.groupBy { it.kategori }
// {
//   "Makanan"   -> [Transaksi(1,...), Transaksi(3,...)],
//   "Transport" -> [Transaksi(2,...), Transaksi(5,...)],
//   "Hiburan"   -> [Transaksi(4,...)]
// }

// groupBy with a value transform: directly take the nominal, not the full object
val nominalPerKategori: Map<String, List<Double>> =
    transaksi.groupBy({ it.kategori }, { it.nominal })
// {"Makanan" -> [85000.0, 120000.0], "Transport" -> [45000.0, 35000.0], ...}

partition — Split into Two Groups #

partition breaks a collection into two lists based on a predicate — the elements that satisfy it and those that don’t.

// ANTI-PATTERN: filtering twice for opposite conditions
val stokAda = produk.filter { it.stok > 0 }
val habis = produk.filter { it.stok == 0 }

// CORRECT: partition — one iteration, two results
val (stokAda, habis) = produk.partition { it.stok > 0 }

// A real example: separate valid and invalid transactions
val (valid, invalid) = transaksi.partition { it.nominal > 0 }

associateBy — Convert a List to a Map #

associateBy is useful for turning a List into a Map for fast lookups.

// ANTI-PATTERN: manual loop to build a Map from a List
val produkById = mutableMapOf<Int, Produk>()
for (p in produk) {
    produkById[p.id] = p
}

// CORRECT: associateBy
data class ProdukLengkap(val id: Int, val nama: String, val harga: Double)

val daftarProduk = listOf(
    ProdukLengkap(1, "Laptop", 15_000_000.0),
    ProdukLengkap(2, "Mouse", 250_000.0),
    ProdukLengkap(3, "Keyboard", 800_000.0)
)

val produkById: Map<Int, ProdukLengkap> = daftarProduk.associateBy { it.id }
val laptop = produkById[1]  // O(1) lookup, not O(n)

// associate: full control over key and value
val namaById: Map<Int, String> = daftarProduk.associate { it.id to it.nama }
// {1 -> "Laptop", 2 -> "Mouse", 3 -> "Keyboard"}

Aggregation Operations #

Aggregation operations summarize an entire collection into a single value.

sum, count, min, max #

val nilai = listOf(85, 92, 78, 95, 88, 71, 90)

val total = nilai.sum()                        // 599
val rata = nilai.average()                     // 85.57...
val terbesar = nilai.max()                     // 95
val terkecil = nilai.min()                     // 71
val jumlah = nilai.count()                     // 7
val lulusan = nilai.count { it >= 80 }         // 5

// sumOf, minOf, maxOf — for specific properties
val totalHarga = produk.sumOf { it.harga }
val hargaTermurah = produk.minOf { it.harga }
val hargaTermahal = produk.maxOf { it.harga }

// minByOrNull, maxByOrNull — returns the element, not its value
val produkTermurah = produk.minByOrNull { it.harga }   // a Produk, not a Double
val produkTermahal = produk.maxByOrNull { it.harga }

reduce and fold #

reduce and fold combine all elements into one value through an accumulation operation. The difference: fold has an initial value, reduce doesn’t.

val angka = listOf(1, 2, 3, 4, 5)

// reduce: the first element becomes the initial accumulator
val jumlah = angka.reduce { acc, angka -> acc + angka }  // 15
val perkalian = angka.reduce { acc, n -> acc * n }       // 120

// fold: you determine the initial value
val jumlahDenganBonus = angka.fold(100) { acc, n -> acc + n }  // 115

// A practical example: build a string from a list
val kata = listOf("Kotlin", "adalah", "bahasa", "yang", "elegan")
val kalimat = kata.fold("") { acc, w -> if (acc.isEmpty()) w else "$acc $w" }
// "Kotlin adalah bahasa yang elegan"

// A more idiomatic version: joinToString
val kalimatIdiomatic = kata.joinToString(" ")

any, all, none #

val nilai = listOf(85, 92, 78, 95, 88)

val adaYangLulus = nilai.any { it >= 80 }      // true
val semuaLulus = nilai.all { it >= 80 }         // false (78 doesn't satisfy)
val tidakAdaGagal = nilai.none { it < 60 }      // true

// A real example: validating an order list
data class ItemPesanan(val nama: String, val stok: Int, val harga: Double)

val pesanan = listOf(
    ItemPesanan("Laptop", 3, 15_000_000.0),
    ItemPesanan("Mouse", 0, 250_000.0),   // out of stock!
    ItemPesanan("Keyboard", 5, 800_000.0)
)

val bisakirimSemua = pesanan.all { it.stok > 0 }       // false
val adaStokHabis = pesanan.any { it.stok == 0 }        // true
val semuaAdaStok = pesanan.none { it.stok == 0 }       // false

Sorting Operations #

sort and sortedBy #

val nama = mutableListOf("Zara", "Andi", "Budi", "Clara")

// sort: in-place, only for MutableList
nama.sort()              // ["Andi", "Budi", "Clara", "Zara"]
nama.sortDescending()    // ["Zara", "Clara", "Budi", "Andi"]

// sorted: produces a new List, safe for read-only
val namaTerurut = listOf("Zara", "Andi", "Budi").sorted()

// sortedBy: sort by a property
val produkUrut = produk.sortedBy { it.harga }             // lowest price first
val produkUrutTurun = produk.sortedByDescending { it.harga }

// sortedWith: a complex comparator — sort by multiple criteria
val produkKompleks = produk.sortedWith(
    compareBy({ it.kategori }, { it.harga })  // category first, then price
)

// compareByDescending for a mix of directions
val produkMixed = produk.sortedWith(
    compareByDescending<Produk> { it.stok }.thenBy { it.harga }
    // high stock first, if equal then lowest price
)

Map Operations #

Map has its own API worth knowing.

val inventaris = mapOf(
    "Laptop" to 10,
    "Mouse" to 50,
    "Keyboard" to 30,
    "Monitor" to 8
)

// Accessing values — use getOrDefault to avoid null
val stokLaptop = inventaris["Laptop"]              // Int? (nullable)
val stokWebcam = inventaris.getOrDefault("Webcam", 0)  // 0 — safe
val stokTablet = inventaris.getOrElse("Tablet") { 0 }  // same, but with a lambda

// Filtering a Map
val stokRendah = inventaris.filter { (_, stok) -> stok < 20 }
// {"Laptop" -> 10, "Monitor" -> 8}

// Transforming values
val stokKritisFlag = inventaris.mapValues { (_, stok) -> stok < 15 }
// {"Laptop" -> true, "Mouse" -> false, ...}

// Transforming keys
val inventarisUpper = inventaris.mapKeys { (nama, _) -> nama.uppercase() }

// any, all on a Map
val adaStokKritis = inventaris.any { (_, stok) -> stok < 10 }  // true (Monitor = 8)
val semuaCukup = inventaris.all { (_, stok) -> stok >= 5 }      // true

Operation Composition #

Kotlin Collections’ true power emerges when you chain multiple operations. Each operation produces a new collection that can immediately continue with the next operation.

data class Karyawan(
    val nama: String,
    val departemen: String,
    val gaji: Double,
    val aktif: Boolean
)

val karyawan = listOf(
    Karyawan("Andi", "Engineering", 15_000_000.0, true),
    Karyawan("Budi", "Marketing", 12_000_000.0, true),
    Karyawan("Clara", "Engineering", 18_000_000.0, true),
    Karyawan("Dedi", "HR", 10_000_000.0, false),
    Karyawan("Eva", "Engineering", 20_000_000.0, true),
    Karyawan("Fani", "Marketing", 13_500_000.0, true)
)

// Case 1: Total salary of active Engineering employees
val totalGajiEngineering = karyawan
    .filter { it.aktif && it.departemen == "Engineering" }
    .sumOf { it.gaji }
// 53_000_000.0

// Case 2: Names of active employees, sorted, joined into a string
val daftarAktif = karyawan
    .filter { it.aktif }
    .sortedBy { it.nama }
    .map { it.nama }
    .joinToString(", ")
// "Andi, Budi, Clara, Eva, Fani"

// Case 3: Average salary per department (only active)
val rataGajiPerDept = karyawan
    .filter { it.aktif }
    .groupBy { it.departemen }
    .mapValues { (_, karyawanDept) ->
        karyawanDept.map { it.gaji }.average()
    }
// {"Engineering" -> 17_666_666.67, "Marketing" -> 12_750_000.0}

// Case 4: The department with the highest total salary
val deptTertinggi = karyawan
    .filter { it.aktif }
    .groupBy { it.departemen }
    .mapValues { (_, list) -> list.sumOf { it.gaji } }
    .maxByOrNull { it.value }
    ?.key
// "Engineering"
flowchart LR
    A["karyawan\n(List&lt;Karyawan&gt;)"] --> B["filter { aktif }"]
    B --> C["groupBy { departemen }"]
    C --> D["mapValues { average() }"]
    D --> E["Map&lt;String, Double&gt;\navg salary/dept"]

Additional Frequently Used Operations #

take, drop, slice #

val angka = listOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)

val tiga = angka.take(3)           // [1, 2, 3]
val tigaAkhir = angka.takeLast(3)  // [8, 9, 10]
val buang3 = angka.drop(3)         // [4, 5, 6, 7, 8, 9, 10]
val slice = angka.slice(2..5)      // [3, 4, 5, 6]

// takeWhile / dropWhile: stop when the condition isn't met
val kecilDari5 = angka.takeWhile { it < 5 }   // [1, 2, 3, 4]
val ab5Keatas = angka.dropWhile { it < 5 }    // [5, 6, 7, 8, 9, 10]

distinct and zip #

// distinct: remove duplicates
val denganDuplikat = listOf(1, 2, 2, 3, 3, 3, 4)
val unik = denganDuplikat.distinct()    // [1, 2, 3, 4]

// distinctBy: unique by a property
val produkDuplikat = listOf(
    Produk("Laptop", 15_000_000.0, 10),
    Produk("Laptop", 14_000_000.0, 5),   // same name
    Produk("Mouse", 250_000.0, 50)
)
val produkUnik = produkDuplikat.distinctBy { it.nama }
// [Produk("Laptop",...), Produk("Mouse",...)] — the second Laptop is discarded

// zip: combine two lists into a list of Pairs
val kunci = listOf("nama", "kota", "negara")
val nilai = listOf("Andi", "Jakarta", "Indonesia")
val gabung = kunci.zip(nilai)
// [("nama", "Andi"), ("kota", "Jakarta"), ("negara", "Indonesia")]

// zip with a direct transform
val map = kunci.zip(nilai) { k, v -> k to v }.toMap()
// {"nama" -> "Andi", "kota" -> "Jakarta", ...}

// unzip: the inverse of zip
val (keys, values) = gabung.unzip()

chunked and windowed #

val data = listOf(1, 2, 3, 4, 5, 6, 7, 8, 9)

// chunked: split into batches of a fixed size
val batch = data.chunked(3)
// [[1, 2, 3], [4, 5, 6], [7, 8, 9]]

// Useful for pagination or batch processing
val halaman = daftarProduk.chunked(10)  // 10 products per page

// windowed: a sliding window
val jendela = data.windowed(3)
// [[1,2,3], [2,3,4], [3,4,5], [4,5,6], [5,6,7], [6,7,8], [7,8,9]]

// windowed with a step
val jendelaStep = data.windowed(3, step = 2)
// [[1,2,3], [3,4,5], [5,6,7], [7,8,9]]
Every transformation operation like map, filter, and groupBy produces a new collection. For very large collections (hundreds of thousands of elements) with many chained operations, consider using Sequence so evaluation is lazy and no unnecessary intermediate collections are created.

When to Use List, Set, or Map #

The choice of collection type isn’t just a preference — it affects code semantics and performance.

flowchart TD
    A{Need key-value\npairs?} -- Yes --> B["Map&lt;K, V&gt;\nO(1) lookup"]
    A -- No --> C{Is order important?}
    C -- Yes --> D["List&lt;T&gt;\norder preserved, index access"]
    C -- No --> E{Duplicates allowed?}
    E -- Yes --> D
    E -- No --> F["Set&lt;T&gt;\nunique, set operations"]
Use List if:
  ✓ Element order matters (data processed sequentially)
  ✓ You need index access (produk[0], produk[5])
  ✓ Duplicates are allowed (logs, transaction history)
  ✓ Most daily use cases

Use Set if:
  ✓ Element uniqueness matters (tags, permissions, categories)
  ✓ You need set operations: intersect, union, subtract
  ✓ Frequently checking whether an element exists: contains() — O(1) vs O(n) on List

Use Map if:
  ✓ You need fast lookups by key
  ✓ Paired data: ID → Object, code → value
  ✓ Results of groupBy
// Set is more appropriate for frequent membership checks
val rolesDiizinkan: Set<String> = setOf("ADMIN", "EDITOR", "MODERATOR")
fun bolehAkses(role: String) = role in rolesDiizinkan  // O(1)

// Map for caches / lookup tables
val kodeNegara: Map<String, String> = mapOf("ID" to "Indonesia", "MY" to "Malaysia")

// Set operations
val aSet = setOf(1, 2, 3, 4, 5)
val bSet = setOf(3, 4, 5, 6, 7)

val irisan = aSet intersect bSet     // {3, 4, 5}
val gabungan = aSet union bSet       // {1, 2, 3, 4, 5, 6, 7}
val selisih = aSet subtract bSet     // {1, 2}

Summary #

  • Immutable by default — always use listOf, setOf, mapOf unless modification is actually needed. MutableList only when you truly need add/remove.
  • map changes each element into another form, filter selects by condition, flatMap flattens nested lists — these three operations solve 80% of transformation needs.
  • groupBy produces a Map<K, List<V>> that’s very useful for aggregating data per category, department, or status.
  • partition splits a collection into two groups in one iteration — more efficient than filtering twice.
  • associateBy turns a List<T> into a Map<K, T> for fast O(1) lookups — avoid manual loops for building a Map from a List.
  • fold and reduce for accumulation; any, all, none for conditions; sumOf, maxByOrNull, minByOrNull for per-property aggregation.
  • Operation composition is the main strength — filter, map, groupBy, sortedBy can be chained to process complex data without manual loops.
  • Choose the right type: List for order, Set for uniqueness and set operations, Map for key-value pairs and fast lookups.
  • buildList / buildMap are the idiomatic way to build collections with conditional logic without creating a mutable and then converting it.
  • For very large collections with many chained operations, consider Sequence so unnecessary intermediate collections aren’t created.

← Previous: Math   Next: Scope Functions →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact