How do I use Kotlin’s null safety features with collections and maps?

Kotlin’s null safety works especially well with collections and maps because Kotlin lets you distinguish between:

  • a nullable collection: List<String>?
  • a collection containing nullable values: List<String?>
  • both: List<String?>?

The same idea applies to maps.

1. Nullable collection vs nullable elements

val names: List<String>? = null
val nicknames: List<String?> = listOf("Ana", null, "Sam")
val maybeNicknames: List<String?>? = null

These mean different things:

List<String>?      // the list itself may be null
List<String?>      // the list exists, but elements may be null
List<String?>?     // both the list and its elements may be null

2. Safely access nullable collections

Use the safe-call operator ?. when the collection itself may be null.

val names: List<String>? = null

println(names?.size)        // null
println(names?.firstOrNull()) // null

If you want a default value, use the Elvis operator ?:.

val count = names?.size ?: 0

println(count) // 0

You can also use orEmpty() to treat a nullable collection as an empty one.

val names: List<String>? = null

for (name in names.orEmpty()) {
    println(name)
}

orEmpty() is often cleaner than repeated null checks.

3. Safely access elements

Avoid direct indexing unless you are sure the index exists.

val names = listOf("Ana", "Ben")

println(names[0]) // Ana
// println(names[5]) // IndexOutOfBoundsException

Use getOrNull() for safe access.

val names = listOf("Ana", "Ben")

val thirdName = names.getOrNull(2)

println(thirdName) // null

Combine it with Elvis for a default:

val displayName = names.getOrNull(2) ?: "Unknown"

println(displayName) // Unknown

4. Filter out null values

If a collection contains nullable elements, use filterNotNull().

val values: List<Int?> = listOf(1, null, 2, null, 3)

val nonNullValues: List<Int> = values.filterNotNull()

println(nonNullValues) // [1, 2, 3]

This is useful because Kotlin understands that the result no longer contains nullable values.

val names: List<String?> = listOf("Ana", null, "Ben")

val lengths = names
    .filterNotNull()
    .map { it.length }

println(lengths) // [3, 3]

5. Transform nullable values with mapNotNull

Use mapNotNull when your transformation may produce null and you only want valid results.

val inputs = listOf("1", "abc", "2", "", "3")

val numbers = inputs.mapNotNull { it.toIntOrNull() }

println(numbers) // [1, 2, 3]

This avoids writing:

val numbers = inputs
    .map { it.toIntOrNull() }
    .filterNotNull()

6. Use firstOrNull, singleOrNull, and find

Many Kotlin collection functions have safe nullable-returning versions.

val users = listOf("Ana", "Ben", "Chris")

val firstLongName = users.firstOrNull { it.length > 10 }
val foundUser = users.find { it.startsWith("B") }

println(firstLongName) // null
println(foundUser)     // Ben

Common safe functions include:

firstOrNull()
lastOrNull()
singleOrNull()
maxOrNull()
minOrNull()
randomOrNull()
getOrNull(index)

These return null instead of throwing when no value is available.

7. Handle nullable map values

Maps are slightly special because map[key] already returns a nullable value.

val ages: Map<String, Int> = mapOf(
    "Ana" to 28,
    "Ben" to 31
)

val anaAge = ages["Ana"]      // Int?
val missingAge = ages["Sam"]  // Int?

println(anaAge)      // 28
println(missingAge)  // null

Even if the map type is Map<String, Int>, lookup returns Int? because the key might not exist.

Use Elvis to provide a default:

val samAge = ages["Sam"] ?: 0

println(samAge) // 0

8. Distinguish missing keys from null values

If your map value type is nullable, there are two possible meanings for null:

val scores: Map<String, Int?> = mapOf(
    "Ana" to 100,
    "Ben" to null
)

println(scores["Ben"]) // null
println(scores["Sam"]) // null

Both return null, but for different reasons:

  • "Ben" exists and has a null value
  • "Sam" does not exist

Use containsKey() when you need to distinguish them.

val key = "Ben"

if (scores.containsKey(key)) {
    println("Key exists with value: ${scores[key]}")
} else {
    println("Key does not exist")
}

9. Use getValue only when the key must exist

getValue() returns a non-nullable value if the map value type is non-nullable, but throws if the key is missing.

val ages = mapOf(
    "Ana" to 28,
    "Ben" to 31
)

val age = ages.getValue("Ana")

println(age) // 28

But this throws:

val missing = ages.getValue("Sam") // NoSuchElementException

Use it when missing keys are a programming error, not normal data.

10. Safely work with nullable maps

If the map itself may be null, use ?., ?:, or orEmpty().

val ages: Map<String, Int>? = null

val anaAge = ages?.get("Ana") ?: 0

println(anaAge) // 0

Or iterate safely:

val ages: Map<String, Int>? = null

for ((name, age) in ages.orEmpty()) {
    println("$name is $age")
}

11. Combine map lookup with let

Use let to run code only when a lookup succeeds.

val ages = mapOf(
    "Ana" to 28,
    "Ben" to 31
)

ages["Ana"]?.let { age ->
    println("Ana is $age years old")
}

If the key is missing, the block is skipped.

ages["Sam"]?.let { age ->
    println("Sam is $age years old")
}

12. Use safe casts with collections

When working with mixed data, use as? and filterIsInstance.

val items: List<Any?> = listOf("Kotlin", 42, null, "Java")

val strings = items.filterIsInstance<String>()

println(strings) // [Kotlin, Java]

For a single value:

val item: Any? = "Kotlin"

val text: String? = item as? String

println(text?.uppercase()) // KOTLIN

13. Common patterns

Default empty list

fun printNames(names: List<String>?) {
    names.orEmpty().forEach { name ->
        println(name)
    }
}

Remove nulls before processing

val emails: List<String?> = listOf("[email protected]", null, "[email protected]")

val normalized = emails
    .filterNotNull()
    .map { it.lowercase() }

println(normalized)

Safe map lookup with default

val settings = mapOf(
    "theme" to "dark"
)

val theme = settings["theme"] ?: "light"

println(theme)

Safe nested lookup

val users: Map<String, Map<String, String>> = mapOf(
    "ana" to mapOf("city" to "Paris")
)

val city = users["ana"]?.get("city") ?: "Unknown"

println(city) // Paris

Quick guide

Situation Use
Collection itself may be null collection?.size, collection.orEmpty()
Element may be null filterNotNull(), ?.let { }
Index may be invalid getOrNull(index)
Need first matching item safely firstOrNull { }, find { }
Transform and skip null results mapNotNull { }
Map key may be missing map[key] ?: default
Need to know if key exists containsKey(key)
Missing key should be an error getValue(key)
Nullable map iteration map.orEmpty()

In general, prefer safe calls, Elvis defaults, orEmpty(), filterNotNull(), mapNotNull(), and safe collection accessors like getOrNull() and firstOrNull() instead of using !! or assuming values are present.

How do I combine Kotlin collections with coroutines and flows for asynchronous processing?

You typically combine Kotlin collections, coroutines, and Flow by using:

  • collections for in-memory data
  • coroutines for concurrency / async work
  • Flow for asynchronous streams of values

Basic idea

If you have a collection:

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

You can turn it into a Flow:

val idFlow = ids.asFlow()

Then process each item asynchronously using Flow operators:

import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*

fun main() = runBlocking {
    val ids = listOf(1, 2, 3, 4, 5)

    ids.asFlow()
        .map { id ->
            fetchUser(id)
        }
        .collect { user ->
            println(user)
        }
}

suspend fun fetchUser(id: Int): String {
    delay(500)
    return "User $id"
}

Here:

  • asFlow() converts the collection into a Flow
  • map { } applies a suspending transformation
  • collect { } starts the flow and consumes results

Sequential asynchronous processing

By default, Flow processes elements sequentially:

ids.asFlow()
    .map { id ->
        fetchUser(id)
    }
    .collect { user ->
        println(user)
    }

Even though fetchUser is suspending, each item is processed one after another.

Concurrent processing with flatMapMerge

If you want to process multiple items concurrently, use flatMapMerge:

import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*

fun main() = runBlocking {
    val ids = listOf(1, 2, 3, 4, 5)

    ids.asFlow()
        .flatMapMerge(concurrency = 3) { id ->
            flow {
                emit(fetchUser(id))
            }
        }
        .collect { user ->
            println(user)
        }
}

suspend fun fetchUser(id: Int): String {
    delay(500)
    return "User $id"
}

This allows up to 3 items to be processed at the same time.

Note: flatMapMerge may emit results out of the original order.

Keeping order while doing concurrent work

If you need concurrency but want results in the original order, you can use async with a collection:

import kotlinx.coroutines.*

fun main() = runBlocking {
    val ids = listOf(1, 2, 3, 4, 5)

    val users = ids.map { id ->
        async {
            fetchUser(id)
        }
    }.awaitAll()

    println(users)
}

suspend fun fetchUser(id: Int): String {
    delay(500)
    return "User $id"
}

awaitAll() returns results in the same order as the original list.

Filtering and transforming Flow values

You can use familiar collection-like operators:

ids.asFlow()
    .filter { id ->
        id % 2 == 0
    }
    .map { id ->
        fetchUser(id)
    }
    .collect { user ->
        println(user)
    }

This is similar to collection processing, but it supports suspending operations.

Collecting a Flow back into a collection

If you need a List again:

val users: List<String> = ids.asFlow()
    .map { id -> fetchUser(id) }
    .toList()

Because toList() collects the flow, it must be called from a coroutine or suspend function.

Using flowOn for background work

You can move upstream processing to a dispatcher:

import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*

fun main() = runBlocking {
    val ids = listOf(1, 2, 3, 4, 5)

    ids.asFlow()
        .map { id ->
            fetchUser(id)
        }
        .flowOn(Dispatchers.IO)
        .collect { user ->
            println(user)
        }
}

This is useful for I/O-bound work such as network or database calls.

Handling errors

Use catch to handle exceptions from upstream operators:

ids.asFlow()
    .map { id ->
        fetchUser(id)
    }
    .catch { error ->
        emit("Fallback user because of: ${error.message}")
    }
    .collect { user ->
        println(user)
    }

Example: process URLs asynchronously

import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*

fun main() = runBlocking {
    val urls = listOf(
        "https://example.com/a",
        "https://example.com/b",
        "https://example.com/c"
    )

    val results = urls.asFlow()
        .flatMapMerge(concurrency = 2) { url ->
            flow {
                val content = download(url)
                emit(url to content.length)
            }
        }
        .toList()

    println(results)
}

suspend fun download(url: String): String {
    delay(1_000)
    return "Content from $url"
}

When to use what

Need Use
Simple in-memory transformation Collection operators: map, filter
Suspending work per item, sequential asFlow().map { suspendCall() }
Suspending work per item, concurrent flatMapMerge
Concurrent work while preserving order map { async { ... } }.awaitAll()
Continuous stream of values Flow
Convert Flow back to List toList()

In short:

val results = items.asFlow()
    .filter { shouldProcess(it) }
    .flatMapMerge(concurrency = 4) { item ->
        flow {
            emit(processAsync(item))
        }
    }
    .toList()

That pattern is a good starting point for asynchronous collection processing with Kotlin coroutines and flows.

How do I implement tail-recursive algorithms with collections in Kotlin?

To implement tail-recursive algorithms with collections in Kotlin, structure your function so that:

  1. The recursive call is the last operation
  2. Any intermediate result is carried in an accumulator
  3. The collection is processed by index, iterator-like state, or remaining sublist
  4. You mark the function with tailrec

Basic pattern

tailrec fun process(
    items: List<Int>,
    index: Int = 0,
    acc: Int = 0
): Int {
    return if (index == items.size) {
        acc
    } else {
        process(items, index + 1, acc + items[index])
    }
}

The recursive call to process(...) is the final operation, so Kotlin can optimize it into a loop.

Example: sum a list

tailrec fun sum(
    numbers: List<Int>,
    index: Int = 0,
    acc: Int = 0
): Int {
    return if (index == numbers.size) {
        acc
    } else {
        sum(numbers, index + 1, acc + numbers[index])
    }
}

fun main() {
    println(sum(listOf(1, 2, 3, 4))) // 10
}

Here, acc stores the running total.

Example: find an element

tailrec fun <T> containsItem(
    items: List<T>,
    target: T,
    index: Int = 0
): Boolean {
    return when {
        index == items.size -> false
        items[index] == target -> true
        else -> containsItem(items, target, index + 1)
    }
}

fun main() {
    val names = listOf("Ada", "Grace", "Linus")

    println(containsItem(names, "Grace")) // true
    println(containsItem(names, "Kotlin")) // false
}

This is tail-recursive because each branch either returns a value directly or calls the function as the last action.

Example: map a collection

For transformations, use an accumulator collection.

tailrec fun <T, R> mapTailrec(
    items: List<T>,
    transform: (T) -> R,
    index: Int = 0,
    acc: MutableList<R> = mutableListOf()
): List<R> {
    return if (index == items.size) {
        acc
    } else {
        acc.add(transform(items[index]))
        mapTailrec(items, transform, index + 1, acc)
    }
}

fun main() {
    val result = mapTailrec(listOf(1, 2, 3)) { it * 2 }
    println(result) // [2, 4, 6]
}

This works, but note that it uses a mutable accumulator internally.

If you want a public immutable-looking API, wrap it:

fun <T, R> List<T>.mapTailrec(transform: (T) -> R): List<R> {
    tailrec fun loop(
        index: Int,
        acc: MutableList<R>
    ): List<R> {
        return if (index == size) {
            acc
        } else {
            acc.add(transform(this[index]))
            loop(index + 1, acc)
        }
    }

    return loop(0, ArrayList(size))
}

Usage:

val doubled = listOf(1, 2, 3).mapTailrec { it * 2 }
println(doubled) // [2, 4, 6]

Example: filter a collection

fun <T> List<T>.filterTailrec(predicate: (T) -> Boolean): List<T> {
    tailrec fun loop(
        index: Int,
        acc: MutableList<T>
    ): List<T> {
        if (index == size) {
            return acc
        }

        val item = this[index]

        if (predicate(item)) {
            acc.add(item)
        }

        return loop(index + 1, acc)
    }

    return loop(0, mutableListOf())
}

Usage:

val evens = listOf(1, 2, 3, 4, 5, 6).filterTailrec { it % 2 == 0 }
println(evens) // [2, 4, 6]

Example: fold with tail recursion

A tail-recursive fold is a good general building block:

tailrec fun <T, R> foldTailrec(
    items: List<T>,
    index: Int = 0,
    acc: R,
    operation: (R, T) -> R
): R {
    return if (index == items.size) {
        acc
    } else {
        foldTailrec(
            items = items,
            index = index + 1,
            acc = operation(acc, items[index]),
            operation = operation
        )
    }
}

Usage:

val numbers = listOf(1, 2, 3, 4)

val sum = foldTailrec(numbers, acc = 0) { acc, n -> acc + n }
val product = foldTailrec(numbers, acc = 1) { acc, n -> acc * n }

println(sum)     // 10
println(product) // 24

Avoid this: non-tail recursion

This is not tail-recursive:

fun sumBad(numbers: List<Int>): Int {
    return if (numbers.isEmpty()) {
        0
    } else {
        numbers.first() + sumBad(numbers.drop(1))
    }
}

The recursive call is not the last operation because Kotlin still has to add numbers.first() after sumBad(...) returns.

This is also inefficient because drop(1) creates new lists repeatedly.

Prefer index-based traversal for lists

For lists, prefer this:

tailrec fun sumGood(
    numbers: List<Int>,
    index: Int = 0,
    acc: Int = 0
): Int {
    return if (index == numbers.size) {
        acc
    } else {
        sumGood(numbers, index + 1, acc + numbers[index])
    }
}

Instead of this:

tailrec fun sumLessEfficient(
    numbers: List<Int>,
    acc: Int = 0
): Int {
    return if (numbers.isEmpty()) {
        acc
    } else {
        sumLessEfficient(numbers.drop(1), acc + numbers.first())
    }
}

Even though the second version is tail-recursive, drop(1) allocates a new list each time, which can make the algorithm much slower.

Important limitation

Kotlin’s tailrec optimization works only for direct self-recursion.

This can be optimized:

tailrec fun countDown(n: Int) {
    if (n > 0) countDown(n - 1)
}

But this cannot:

fun even(n: Int): Boolean {
    return n == 0 || odd(n - 1)
}

fun odd(n: Int): Boolean {
    return n != 0 && even(n - 1)
}

Mutual recursion is not optimized by tailrec.

Practical advice

For collection algorithms in Kotlin:

  • Use tailrec only when recursion makes the logic clearer.
  • Use an accumulator for partial results.
  • Prefer index-based traversal over drop, take, or subList loops.
  • For transformations, use a mutable accumulator internally and return it as List<T>.
  • Remember that Kotlin’s standard library functions like map, filter, fold, any, and find are usually the best choice in production code.

In most real Kotlin code, this:

val result = numbers.fold(0) { acc, n -> acc + n }

is preferable to writing your own recursive fold unless you are learning, implementing custom traversal logic, or working with recursive data structures.

How do I use sequences to optimize performance with large Kotlin collections?

In Kotlin, sequences let you process large collections lazily, which can reduce temporary allocations and improve performance for chained operations.

The problem with regular collections

Collection operations like map, filter, and flatMap are usually eager:

val result = users
    .filter { it.isActive }
    .map { it.email }
    .take(10)

With a List, Kotlin typically creates intermediate collections:

  1. one list after filter
  2. another list after map
  3. then takes 10 items

For small collections, this is fine. For large collections, it can waste memory and CPU.

Use asSequence()

Convert the collection to a sequence:

val result = users
    .asSequence()
    .filter { it.isActive }
    .map { it.email }
    .take(10)
    .toList()

Now each element flows through the pipeline one at a time:

user -> filter -> map -> maybe included in result

Also, because of take(10), processing can stop as soon as 10 matching items are found.

Example

data class User(
    val name: String,
    val email: String,
    val isActive: Boolean
)

val emails = users
    .asSequence()
    .filter { it.isActive }
    .map { it.email.lowercase() }
    .distinct()
    .take(100)
    .toList()

This avoids building large intermediate lists before getting the final 100 emails.

When sequences help

Sequences are usually useful when you have:

  • large collections
  • multiple chained operations
  • short-circuiting operations, such as:
    • take
    • first
    • firstOrNull
    • any
    • all
    • none
    • find

Example:

val firstAdminEmail = users
    .asSequence()
    .filter { it.isActive }
    .filter { it.role == "admin" }
    .map { it.email }
    .firstOrNull()

This stops as soon as the first matching user is found.

When sequences may not help

Sequences are not always faster. They can be slower for:

  • small collections
  • simple one-step operations
  • cases where you need the entire result anyway
  • performance-critical tight loops where sequence overhead matters

For example, this may not benefit much:

val names = users.map { it.name }

A plain collection operation is often fine here.

Common pattern

Use this pattern:

val result = largeList
    .asSequence()
    .filter { condition(it) }
    .map { transform(it) }
    .take(50)
    .toList()

The important parts are:

  • asSequence() starts lazy processing
  • intermediate operations stay lazy
  • terminal operations execute the pipeline

Terminal operations include:

toList()
count()
first()
firstOrNull()
sumOf { ... }
any { ... }
forEach { ... }

Rule of thumb

Use sequences when your pipeline is large, chained, and potentially short-circuited.

For small or simple transformations, prefer normal collection operations for readability and often better performance.

How do I use map, filter and foreach with Kotlin collections?

In Kotlin collections:

  • map transforms each element into a new value.
  • filter keeps only elements that match a condition.
  • forEach performs an action for each element.

map: transform elements

Use map when you want to create a new collection by changing each item.

val numbers = listOf(1, 2, 3, 4)

val doubled = numbers.map { number ->
    number * 2
}

println(doubled) // [2, 4, 6, 8]

You can use it when the lambda has one parameter:

val numbers = listOf(1, 2, 3, 4)

val doubled = numbers.map { it * 2 }

println(doubled) // [2, 4, 6, 8]

filter: keep matching elements

Use filter when you want only items that satisfy a condition.

val numbers = listOf(1, 2, 3, 4, 5, 6)

val evenNumbers = numbers.filter { it % 2 == 0 }

println(evenNumbers) // [2, 4, 6]

Another example with strings:

val names = listOf("Alice", "Bob", "Charlie", "Anna")

val namesStartingWithA = names.filter { it.startsWith("A") }

println(namesStartingWithA) // [Alice, Anna]

forEach: perform an action

Use forEach when you want to do something with each element, such as printing.

val names = listOf("Alice", "Bob", "Charlie")

names.forEach { name ->
    println(name)
}

Using it:

val names = listOf("Alice", "Bob", "Charlie")

names.forEach {
    println(it)
}

Chaining them together

You can combine filter, map, and forEach.

val numbers = listOf(1, 2, 3, 4, 5, 6)

numbers
    .filter { it % 2 == 0 }
    .map { it * 10 }
    .forEach { println(it) }

Output:

20
40
60

This means:

  1. Keep only even numbers: [2, 4, 6]
  2. Multiply each by 10: [20, 40, 60]
  3. Print each result

Example with objects

data class User(
    val name: String,
    val age: Int
)

val users = listOf(
    User("Alice", 25),
    User("Bob", 17),
    User("Charlie", 30)
)

val adultNames = users
    .filter { it.age >= 18 }
    .map { it.name }

println(adultNames) // [Alice, Charlie]

Important difference

map and filter return new collections:

val numbers = listOf(1, 2, 3)

val doubled = numbers.map { it * 2 }

println(numbers) // [1, 2, 3]
println(doubled) // [2, 4, 6]

forEach is usually used for side effects and does not create a transformed list:

val numbers = listOf(1, 2, 3)

numbers.forEach { println(it) }

Quick summary

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

val squared = numbers.map { it * it }
// [1, 4, 9, 16, 25]

val greaterThanTwo = numbers.filter { it > 2 }
// [3, 4, 5]

numbers.forEach { println(it) }
// Prints each number

Use:

  • map when you want to transform values
  • filter when you want to select values
  • forEach when you want to perform an action for each value