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 use inline functions and reified types in collection processing?

In Kotlin, inline functions and reified type parameters are especially useful in collection processing when you want to write generic, type-safe utilities that still need access to the actual runtime type.

Normally, generic type information is erased at runtime, but reified type parameters in inline functions let you do checks like is T, as T, or filterIsInstance<T>().

Basic idea

inline fun <reified T> Iterable<*>.onlyOfType(): List<T> {
    return this.filterIsInstance<T>()
}

Usage:

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

val strings = items.onlyOfType<String>()

println(strings) // [Kotlin, Java]

Because T is reified, Kotlin knows at runtime that T is String.

Why inline is required

A reified type parameter is only allowed on an inline function:

inline fun <reified T> someFunction(value: Any): Boolean {
    return value is T
}

This works because the compiler substitutes the function body at the call site and preserves the concrete type.

This would not compile:

fun <T> someFunction(value: Any): Boolean {
    return value is T // Error: cannot check for instance of erased type
}

Filtering a collection by type

inline fun <reified T> List<*>.filterByType(): List<T> {
    return filterIsInstance<T>()
}

Example:

val mixed = listOf(1, "two", 3L, "four", 5.0)

val strings = mixed.filterByType<String>()
val ints = mixed.filterByType<Int>()

println(strings) // [two, four]
println(ints)    // [1]

Mapping only matching elements

You can combine reified type checks with mapNotNull:

inline fun <reified T, R> Iterable<*>.mapIfType(
    transform: (T) -> R
): List<R> {
    return mapNotNull { item ->
        if (item is T) transform(item) else null
    }
}

Usage:

val values: List<Any> = listOf("one", 2, "three", 4)

val lengths = values.mapIfType<String> { it.length }

println(lengths) // [3, 5]

Finding the first item of a type

inline fun <reified T> Iterable<*>.firstOfTypeOrNull(): T? {
    return firstOrNull { it is T } as? T
}

Usage:

val items: List<Any> = listOf(10, "hello", 20)

val firstString = items.firstOfTypeOrNull<String>()

println(firstString) // hello

A slightly cleaner version uses filterIsInstance:

inline fun <reified T> Iterable<*>.firstOfTypeOrNull(): T? {
    return filterIsInstance<T>().firstOrNull()
}

Grouping elements by runtime type

inline fun <reified T> Iterable<*>.partitionByType(): Pair<List<T>, List<Any?>> {
    val matching = mutableListOf<T>()
    val others = mutableListOf<Any?>()

    for (item in this) {
        if (item is T) {
            matching += item
        } else {
            others += item
        }
    }

    return matching to others
}

Usage:

val data = listOf("a", 1, "b", 2.0, null)

val result = data.partitionByType<String>()

println(result.first)  // [a, b]
println(result.second) // [1, 2.0, null]

Processing collections with inline lambdas

inline is also useful for performance when you pass lambdas to collection-like helper functions. It avoids allocating a function object in many cases.

inline fun <T, R> Iterable<T>.transformEach(
    transform: (T) -> R
): List<R> {
    val result = ArrayList<R>()

    for (item in this) {
        result += transform(item)
    }

    return result
}

Usage:

val numbers = listOf(1, 2, 3)

val doubled = numbers.transformEach { it * 2 }

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

Combining inline and reified

A common pattern is a type-safe processing function:

inline fun <reified T, R> Iterable<*>.processType(
    transform: (T) -> R
): List<R> {
    return mapNotNull { item ->
        if (item is T) {
            transform(item)
        } else {
            null
        }
    }
}

Example:

val events: List<Any> = listOf(
    "login",
    404,
    "logout",
    500
)

val uppercasedEvents = events.processType<String> {
    it.uppercase()
}

println(uppercasedEvents) // [LOGIN, LOGOUT]

Example with sealed classes

sealed interface Event

data class ClickEvent(val x: Int, val y: Int) : Event
data class TextEvent(val text: String) : Event
data class ErrorEvent(val message: String) : Event

inline fun <reified T : Event> Iterable<Event>.ofEventType(): List<T> {
    return filterIsInstance<T>()
}

Usage:

val events: List<Event> = listOf(
    ClickEvent(10, 20),
    TextEvent("hello"),
    ErrorEvent("failed"),
    TextEvent("world")
)

val textEvents = events.ofEventType<TextEvent>()

println(textEvents.map { it.text }) // [hello, world]

Important limitations

1. reified only works in inline functions

inline fun <reified T> ok(value: Any) = value is T

But this does not work:

fun <T> notOk(value: Any) = value is T

2. It does not fully solve nested generic type erasure

This can be misleading:

val data: List<Any> = listOf(listOf("a"), listOf(1))

val stringLists = data.filterIsInstance<List<String>>()

Because of type erasure, the runtime can check that each item is a List, but not that each list contains String.

A safer approach:

val stringLists = data
    .filterIsInstance<List<*>>()
    .filter { list -> list.all { it is String } }
    .map { list -> list.map { it as String } }

Practical collection utilities

inline fun <reified T> Iterable<*>.countOfType(): Int {
    return count { it is T }
}

inline fun <reified T> Iterable<*>.containsType(): Boolean {
    return any { it is T }
}

inline fun <reified T> Iterable<*>.withoutType(): List<Any?> {
    return filterNot { it is T }
}

Usage:

val values = listOf("a", 1, "b", 2.0)

println(values.countOfType<String>())   // 2
println(values.containsType<Double>())  // true
println(values.withoutType<String>())   // [1, 2.0]

Rule of thumb

Use inline + reified when:

  • You need to check item is T
  • You need to cast with as T or as? T
  • You want to call APIs like filterIsInstance<T>()
  • You want type-safe helpers for heterogeneous collections
  • You want to avoid passing KClass<T> or Class<T> manually

For ordinary collection transformations where no runtime type check is needed, a regular generic function is usually enough:

fun <T, R> Iterable<T>.mapCustom(transform: (T) -> R): List<R> {
    return map(transform)
}

How do I write domain-specific extensions for collections in Kotlin?

Domain-specific collection extensions in Kotlin are usually extension functions or extension properties on Iterable<T>, List<T>, Set<T>, Map<K, V>, or more specific collection types that encode concepts from your domain.

They let you write expressive code like:

val overdueInvoices = invoices.overdue()
val activeUsers = users.active()
val totalRevenue = orders.totalRevenue()

instead of repeatedly writing filtering, grouping, or aggregation logic inline.

1. Start with simple extension functions

Suppose you have a domain model:

import java.math.BigDecimal
import java.time.LocalDate

data class Invoice(
    val id: String,
    val customerId: String,
    val amount: BigDecimal,
    val dueDate: LocalDate,
    val paid: Boolean
)

You can add collection extensions for common domain queries:

import java.time.LocalDate

fun Iterable<Invoice>.overdue(today: LocalDate = LocalDate.now()): List<Invoice> =
    filter { invoice ->
        !invoice.paid && invoice.dueDate.isBefore(today)
    }

fun Iterable<Invoice>.paid(): List<Invoice> =
    filter { it.paid }

fun Iterable<Invoice>.unpaid(): List<Invoice> =
    filterNot { it.paid }

Usage:

val overdueInvoices = invoices.overdue()
val unpaidInvoices = invoices.unpaid()

2. Add domain-specific aggregation

Collections often need totals, counts, averages, or summaries.

import java.math.BigDecimal

fun Iterable<Invoice>.totalAmount(): BigDecimal =
    fold(BigDecimal.ZERO) { total, invoice ->
        total + invoice.amount
    }

fun Iterable<Invoice>.totalPaidAmount(): BigDecimal =
    paid().totalAmount()

fun Iterable<Invoice>.totalOutstandingAmount(): BigDecimal =
    unpaid().totalAmount()

Usage:

val outstanding = invoices.totalOutstandingAmount()

This is easier to understand than:

val outstanding = invoices
    .filterNot { it.paid }
    .fold(BigDecimal.ZERO) { total, invoice -> total + invoice.amount }

3. Group collections using domain language

You can wrap common groupBy patterns:

fun Iterable<Invoice>.groupedByCustomer(): Map<String, List<Invoice>> =
    groupBy { it.customerId }

fun Iterable<Invoice>.overdueByCustomer(
    today: LocalDate = LocalDate.now()
): Map<String, List<Invoice>> =
    overdue(today).groupedByCustomer()

Usage:

val invoicesByCustomer = invoices.groupedByCustomer()
val overdueByCustomer = invoices.overdueByCustomer()

4. Prefer Iterable<T> when possible

If your function only needs to iterate, prefer Iterable<T>:

fun Iterable<Invoice>.overdue(): List<Invoice> =
    filter { !it.paid && it.dueDate.isBefore(LocalDate.now()) }

Use List<T> only when list-specific behavior matters, such as ordering by index:

fun List<Invoice>.firstOverdueOrNull(
    today: LocalDate = LocalDate.now()
): Invoice? =
    firstOrNull { !it.paid && it.dueDate.isBefore(today) }

Use Sequence<T> if you want lazy processing for large pipelines:

fun Sequence<Invoice>.overdue(
    today: LocalDate = LocalDate.now()
): Sequence<Invoice> =
    filter { !it.paid && it.dueDate.isBefore(today) }

5. Use extension properties for simple derived facts

If a value does not need parameters, an extension property can read nicely:

val Iterable<Invoice>.totalAmount: BigDecimal
    get() = fold(BigDecimal.ZERO) { total, invoice ->
        total + invoice.amount
    }

val Iterable<Invoice>.hasOverdueInvoices: Boolean
    get() = any { !it.paid && it.dueDate.isBefore(LocalDate.now()) }

Usage:

if (invoices.hasOverdueInvoices) {
    println("Some invoices are overdue")
}

println(invoices.totalAmount)

Use properties for cheap, parameterless concepts. Use functions when the operation accepts arguments or does meaningful work.

6. Create richer domain operations

For example, with an order domain:

import java.math.BigDecimal

data class Order(
    val id: String,
    val customerId: String,
    val status: OrderStatus,
    val total: BigDecimal
)

enum class OrderStatus {
    Draft,
    Submitted,
    Paid,
    Cancelled
}

Extensions:

fun Iterable<Order>.paid(): List<Order> =
    filter { it.status == OrderStatus.Paid }

fun Iterable<Order>.submitted(): List<Order> =
    filter { it.status == OrderStatus.Submitted }

fun Iterable<Order>.cancelled(): List<Order> =
    filter { it.status == OrderStatus.Cancelled }

fun Iterable<Order>.totalRevenue(): BigDecimal =
    paid().fold(BigDecimal.ZERO) { total, order ->
        total + order.total
    }

fun Iterable<Order>.forCustomer(customerId: String): List<Order> =
    filter { it.customerId == customerId }

Usage:

val revenue = orders.totalRevenue()
val customerOrders = orders.forCustomer("customer-123")
val paidCustomerOrders = orders.forCustomer("customer-123").paid()

7. Avoid making extensions too generic

This is usually good:

fun Iterable<Order>.totalRevenue(): BigDecimal =
    paid().fold(BigDecimal.ZERO) { total, order -> total + order.total }

This is probably too vague:

fun Iterable<Order>.goodOnes(): List<Order> =
    filter { it.status == OrderStatus.Paid }

Choose names that reflect your domain clearly: overdue, billable, fulfilled, activeSubscriptions, totalRevenue, groupedByCustomer, etc.

8. Consider nullable and empty collections

Be explicit about what happens for empty collections:

fun Iterable<Invoice>.largestInvoiceOrNull(): Invoice? =
    maxByOrNull { it.amount }

fun Iterable<Invoice>.averageAmountOrZero(): BigDecimal {
    val invoices = toList()

    if (invoices.isEmpty()) {
        return BigDecimal.ZERO
    }

    val total = invoices.totalAmount()
    return total.divide(BigDecimal(invoices.size))
}

Prefer OrNull suffixes when returning nullable results:

fun Iterable<Invoice>.oldestUnpaidInvoiceOrNull(): Invoice? =
    unpaid().minByOrNull { it.dueDate }

9. Use generic extensions when there is a reusable domain interface

If several entities share domain behavior, define an interface:

interface HasCustomer {
    val customerId: String
}

data class Invoice(
    val id: String,
    override val customerId: String,
    val amount: BigDecimal,
    val paid: Boolean
) : HasCustomer

data class Order(
    val id: String,
    override val customerId: String,
    val total: BigDecimal
) : HasCustomer

Then write a generic extension:

fun <T : HasCustomer> Iterable<T>.forCustomer(customerId: String): List<T> =
    filter { it.customerId == customerId }

fun <T : HasCustomer> Iterable<T>.groupedByCustomer(): Map<String, List<T>> =
    groupBy { it.customerId }

Usage:

val customerInvoices = invoices.forCustomer("customer-123")
val customerOrders = orders.forCustomer("customer-123")

10. Keep mutability clear

Prefer returning new collections:

fun Iterable<Order>.withoutCancelled(): List<Order> =
    filter { it.status != OrderStatus.Cancelled }

Be careful with extensions that mutate the receiver:

fun MutableList<Order>.removeCancelled() {
    removeAll { it.status == OrderStatus.Cancelled }
}

If an extension mutates state, make that obvious in the name: removeCancelled, sortByPriorityInPlace, markAllAsPaid, etc.

11. Organize extensions by domain

A common structure is:

src/main/kotlin/
  billing/
    Invoice.kt
    InvoiceCollectionExtensions.kt
  orders/
    Order.kt
    OrderCollectionExtensions.kt

Example:

package billing

fun Iterable<Invoice>.overdue(): List<Invoice> =
    filter { !it.paid }

fun Iterable<Invoice>.totalAmount(): BigDecimal =
    fold(BigDecimal.ZERO) { total, invoice -> total + invoice.amount }

Then import them where needed:

import billing.overdue
import billing.totalAmount

Good rules of thumb

  • Use Iterable<T> for most collection extensions.
  • Use List<T> only when order or index matters.
  • Use Sequence<T> for lazy pipelines.
  • Use extension properties for cheap, parameterless values.
  • Use extension functions for parameterized or non-trivial operations.
  • Prefer clear domain names over technical names.
  • Return new collections unless mutation is intentional.
  • Use OrNull suffixes for nullable lookups.
  • Keep extensions close to the domain they describe.

A typical domain-specific collection extension looks like this:

fun Iterable<Invoice>.overdue(today: LocalDate = LocalDate.now()): List<Invoice> =
    filter { !it.paid && it.dueDate.isBefore(today) }

And its value is that the rest of your application can speak in domain terms:

val amountToCollect = invoices
    .overdue()
    .totalAmount()

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 lazy evaluation and avoid unnecessary computation in Kotlin?

In Kotlin, you can use lazy evaluation mainly with:

  1. lazy { ... } for lazily initialized properties
  2. Sequence for lazy collection-style pipelines
  3. Short-circuiting operators/functions like &&, ||, any, first, take
  4. Lambdas to defer expensive work until needed

1. Lazy property initialization with lazy

Use lazy when a value is expensive to create and may not always be needed.

val expensiveValue: String by lazy {
    println("Computing expensive value...")
    loadExpensiveData()
}

fun loadExpensiveData(): String {
    return "Result"
}

fun main() {
    println("Before access")

    // Computation happens here, on first access
    println(expensiveValue)

    // Reuses cached value, does not recompute
    println(expensiveValue)
}

Output:

Before access
Computing expensive value...
Result
Result

lazy computes the value only once by default.


2. Lazy collection processing with Sequence

Kotlin collections like List process intermediate operations eagerly:

val result = listOf(1, 2, 3, 4, 5)
    .map {
        println("map $it")
        it * 2
    }
    .filter {
        println("filter $it")
        it > 5
    }
    .first()

With a regular List, map creates a full intermediate list before filter runs.

Use asSequence() to process elements one at a time:

val result = listOf(1, 2, 3, 4, 5)
    .asSequence()
    .map {
        println("map $it")
        it * 2
    }
    .filter {
        println("filter $it")
        it > 5
    }
    .first()

println(result)

This stops as soon as first() finds a matching value.

For the example above, it processes only as much as needed:

map 1
filter 2
map 2
filter 4
map 3
filter 6
6

It does not process 4 or 5.


3. Prefer terminal operations that short-circuit

Some operations stop early when the answer is known.

Examples:

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

val hasEven = numbers.any { it % 2 == 0 }
val firstLarge = numbers.firstOrNull { it > 3 }
val firstThree = numbers.take(3)

These can avoid unnecessary work.

With sequences, this becomes especially useful:

val firstMatch = numbers
    .asSequence()
    .map { expensiveTransform(it) }
    .firstOrNull { it.isValid() }

This avoids transforming every element if a valid one appears early.


4. Use boolean short-circuiting

Kotlin’s && and || operators short-circuit.

if (user != null && user.isActive) {
    println("Active user")
}

user.isActive is checked only if user != null.

Another example:

if (cachedResult != null || computeFallback()) {
    println("We have a result")
}

computeFallback() runs only if cachedResult != null is false.


5. Defer expensive work with lambdas

If a function may not need a value, pass a lambda instead of the already-computed value.

Avoid this:

fun logDebug(message: String) {
    if (isDebugEnabled()) {
        println(message)
    }
}

logDebug("Result: ${expensiveComputation()}")

The expensive computation happens before logDebug is called.

Prefer this:

fun logDebug(message: () -> String) {
    if (isDebugEnabled()) {
        println(message())
    }
}

logDebug {
    "Result: ${expensiveComputation()}"
}

Now expensiveComputation() runs only if debug logging is enabled.


6. Use getOrPut for lazy map values

If you cache computed values in a map, use getOrPut:

val cache = mutableMapOf<String, Data>()

fun getData(key: String): Data {
    return cache.getOrPut(key) {
        loadData(key)
    }
}

loadData(key) only runs if the key is missing.


7. Build custom lazy sequences

You can create sequences using sequence { ... } and yield.

val generatedNumbers = sequence {
    println("Generating 1")
    yield(1)

    println("Generating 2")
    yield(2)

    println("Generating 3")
    yield(3)
}

val first = generatedNumbers.first()

println(first)

Only the first value is generated:

Generating 1
1

8. Be careful: sequences are not always faster

Use Sequence when:

  • You have a long chain of operations
  • You process large collections
  • You use short-circuiting terminal operations like first, any, take
  • You want to avoid intermediate collections

Regular collections may be better when:

  • The collection is small
  • The pipeline is simple
  • You need the final result as a list anyway
  • Performance overhead from sequences outweighs savings

Example where Sequence is useful:

val result = users
    .asSequence()
    .filter { it.isActive }
    .map { loadProfile(it) }
    .firstOrNull { it.hasPremiumAccess }

Example where a regular list is fine:

val names = users.map { it.name }

Common patterns

Lazy property

class ReportService {
    private val reportTemplate by lazy {
        loadTemplateFromDisk()
    }

    fun generateReport(): Report {
        return Report(reportTemplate)
    }
}

Lazy pipeline

val result = items
    .asSequence()
    .filter { shouldKeep(it) }
    .map { transform(it) }
    .take(10)
    .toList()

Lazy fallback

val value = cachedValue ?: computeExpensiveFallback()

computeExpensiveFallback() only runs if cachedValue is null.


Summary

Use:

  • by lazy { ... } for values initialized on first use
  • asSequence() for lazy collection pipelines
  • any, firstOrNull, take, etc. for short-circuiting
  • &&, ||, and ?: to avoid unnecessary calls
  • Lambdas like () -> String to defer expensive argument construction
  • getOrPut for lazy cache population

A good rule of thumb:

val result = data
    .asSequence()
    .filter { cheapCheck(it) }
    .map { expensiveTransform(it) }
    .firstOrNull { finalCheck(it) }

Filter early, transform late, and stop as soon as you have what you need.