Stop Using mutableListOf + toList(): Meet Kotlin’s buildList, buildMap, and buildSet

A Familiar But Slightly Awkward Pattern

If you’ve written Kotlin for a while, you’ve almost certainly written code like this:

fun getActiveUsers(users: List): List {
    val result = mutableListOf()
    for (user in users) {
        if (user.isActive) result.add(user)
        if (user.isPremium) result.add(user.copy(label = "Premium"))
    }
    return result.toList() // convert back to read-only
}

The pattern works, but it’s ceremonial: create a mutable collection, build it up imperatively, then convert it back to a read-only version. The mutable list is just scaffolding — what you actually want is the final read-only result.

Kotlin 1.6 introduced a cleaner alternative: buildList, buildMap, and buildSet. These are standard library functions that let you construct read-only collections using an imperative builder block, without ever exposing a mutable reference outside the builder.

buildList: Construct a List Without the Boilerplate

buildList takes a lambda where this is a MutableList. When the lambda returns, the list is sealed into a read-only List and returned. The mutable state never leaks:

val numbers = buildList {
    add(1)
    add(2)
    addAll(listOf(3, 4, 5))
    if (System.currentTimeMillis() % 2 == 0L) add(6) // conditional logic is fine
}
// numbers is List, fully read-only
println(numbers) // [1, 2, 3, 4, 5] or [1, 2, 3, 4, 5, 6]

You can use any MutableList operation inside the block: add, addAll, remove, set, sortWith, and so on. The key insight is that the complexity of building the list stays inside the builder — callers always receive a clean, immutable result.

Rewriting the earlier example:

fun getActiveUsers(users: List): List = buildList {
    for (user in users) {
        if (user.isActive) add(user)
        if (user.isPremium) add(user.copy(label = "Premium"))
    }
}

Cleaner, no intermediate variable, and the intent is immediately obvious: we’re building a list.

buildMap: Construct a Map the Same Way

buildMap works identically, but this inside the lambda is a MutableMap. This is especially useful when your map construction involves non-trivial logic:

data class Config(val key: String, val value: String, val isSecret: Boolean)

fun buildPublicConfig(configs: List): Map = buildMap {
    for (config in configs) {
        if (!config.isSecret) {
            put(config.key, config.value)
        }
    }
    // Always include the app version
    put("app_version", BuildConfig.VERSION_NAME)
}

You can also use putAll, getOrPut, remove, and any other MutableMap API. A particularly handy pattern is using getOrPut to build a grouping map:

fun groupByFirstLetter(words: List): Map> = buildMap {
    for (word in words) {
        val key = word.first().lowercaseChar()
        getOrPut(key) { mutableListOf() }.also { (it as MutableList).add(word) }
    }
}

buildSet: When You Need Deduplication

buildSet gives you a MutableSet inside the builder and returns a read-only Set. It shines when you want to merge collections while automatically removing duplicates:

fun mergePermissions(
    userPermissions: List,
    rolePermissions: List,
    extraPermissions: List
): Set = buildSet {
    addAll(userPermissions)
    addAll(rolePermissions)
    addAll(extraPermissions)
    // You can also remove permissions conditionally
    remove("ADMIN_DELETE") // always strip this one for safety
}

Specifying an Initial Capacity

All three builders accept an optional capacity parameter. If you have a rough idea of how many elements you’ll add, providing it avoids internal array resizing:

val items = buildList(capacity = 256) {
    repeat(200) { add("item_$it") }
}

This is purely a performance hint — the collection will still grow if you exceed the initial capacity.

How Do These Compare to Other Approaches?

You might wonder how buildList compares to listOf + spread operator, sequence().toList(), or chained map/filter calls.

listOf is perfect for static, known-at-compile-time lists. As soon as you have conditional logic or loops, you’re back to a mutable intermediate.

Functional chains (filter, map, flatMap) are great for transformations over an existing source. But they don’t compose well when you need to build a collection from multiple sources or with logic that doesn’t fit neatly into a transform pipeline.

buildList fills the gap: imperative construction logic, zero mutable leakage, and a single-expression style that works well with Kotlin’s expression-body functions.

Practical Android Example: Building a RecyclerView Item List

Here’s a realistic scenario — building a mixed list of items for a RecyclerView adapter that includes headers, content rows, and a footer:

sealed class ListItem {
    data class Header(val title: String) : ListItem()
    data class Row(val data: UserData) : ListItem()
    object Footer : ListItem()
}

fun buildAdapterItems(
    sections: List
, showFooter: Boolean ): List = buildList { for (section in sections) { add(ListItem.Header(section.title)) for (user in section.users) { add(ListItem.Row(user.toData())) } } if (showFooter) add(ListItem.Footer) }

Compare this to the same code written with mutableListOf + toList() — the builder version has less noise and makes the intent clearer.

Summary

buildList, buildMap, and buildSet are small but high-quality additions to the Kotlin standard library. They close the gap between the convenience of mutable building and the safety of read-only collections — without the toList() ceremony at the end. Next time you find yourself writing val result = mutableListOf<T>()return result.toList(), reach for a builder instead.


This post was written by a human with the help of Claude, an AI assistant by Anthropic.

Scroll to Top