Higher-order functions and lambdas in Kotlin

1. Overview

In Kotlin, functions are “first-class citizens”. You can assign a function to a variable, pass it as a parameter, or return it from another function. A function that takes or returns another function is called a higher-order function. The most concise way to create such a function is with a lambda.

This article covers function types, lambda syntax, function references, closures, lambdas with receivers, and the inline keyword. By the end, you’ll be able to write your own higher-order functions. You’ll also know how to avoid common pitfalls.

2. Function types

Every function in Kotlin has a type, called a function type. The syntax consists of a parameter type list in parentheses, an arrow, and then the return type:

fun main() {
    val add: (Int, Int) -> Int = { a, b -> a + b }
    val greet: (String) -> Unit = { name -> println("Hello, $name") }
    val now: () -> Long = { System.currentTimeMillis() }

    println(add(2, 3))
    greet("Alice")
    println(now() > 0)
}

Explanation:

  • (Int, Int) -> Int is a function that takes two Ints and returns an Int.
  • (String) -> Unit is a function that doesn’t return a meaningful value, similar to void in Java.
  • () -> Long is a function with no parameters.
  • You call a function variable like a regular function: add(2, 3). This is equivalent to add.invoke(2, 3).
5
Hello, Alice
true

Function types can also be nullable. In that case, you wrap the entire type in parentheses: ((Order) -> Unit)?. To call it safely, use callback?.invoke(order).

3. Writing your first higher-order function

We’ll use a simple model throughout this article. We use Long for amounts (in cents) to avoid floating-point rounding errors:

data class Order(val id: String, val customer: String, val amount: Long)

fun calculateTotal(orders: List<Order>, priceOf: (Order) -> Long): Long {
    var total = 0L
    for (order in orders) {
        total += priceOf(order)
    }
    return total
}

fun main() {
    val orders = listOf(
        Order("A1", "Alice", 100_000),
        Order("A2", "Bob", 250_000),
        Order("A3", "Alice", 50_000),
    )

    val full = calculateTotal(orders) { it.amount }
    val discounted = calculateTotal(orders) { it.amount * 90 / 100 }

    println(full)
    println(discounted)
}

calculateTotal is a higher-order function because the parameter priceOf has type (Order) -> Long. The function doesn’t know how to calculate the price of each order. It delegates that to the caller. This way, the same loop can work for both full price and discounted price.

400000
360000

4. Lambda syntax

4.1. Different forms

A lambda always goes in {}. Parameters come before ->, and the body comes after. The last expression is the return value; you don’t need to write return:

val isLarge: (Order) -> Boolean = { order ->
    val threshold = 200_000
    order.amount > threshold   // return value
}

val isLargeShort: (Order) -> Boolean = { it.amount > 200_000 }

val ignoreSecond: (Order, Int) -> String = { order, _ -> order.id }
  • When a lambda has one parameter, you can omit the declaration and use the implicit name it.
  • Unused parameters should be named _ to make your intent clear and avoid compiler warnings.

4.2. Trailing lambda

If the last parameter of a function is a function type, you can place the lambda outside the parentheses. If it’s the only parameter, you can omit the () entirely:

val orders = listOf(
    Order("A1", "Alice", 100_000),
    Order("A2", "Bob", 250_000),
)

val large = orders.filter { it.amount > 200_000 }
val total = calculateTotal(orders) { it.amount }
val sorted = orders.sortedByDescending { it.amount }.map { it.id }

println(large.map { it.id })
println(sorted)
[A2]
[A2, A1]

This is why Kotlin collection APIs read almost like natural language. All of filter, map, and sortedByDescending in the standard library are higher-order functions.

5. Function references

When you already have a function with the right signature, you don’t need to wrap it in a lambda. Use the :: operator instead:

fun isVip(order: Order): Boolean = order.amount >= 200_000

class TaxService(private val ratePercent: Long) {
    fun withTax(order: Order): Long = order.amount * (100 + ratePercent) / 100
}

fun main() {
    val orders = listOf(Order("A1", "Alice", 100_000), Order("A2", "Bob", 250_000))
    val taxService = TaxService(10)

    println(orders.filter(::isVip).map { it.id })   // top-level function
    println(orders.map(Order::amount))              // property reference
    println(calculateTotal(orders, taxService::withTax)) // bound reference
}
  • ::isVip refers to a top-level function.
  • Order::amount refers to a property. It has type (Order) -> Long.
  • taxService::withTax is a bound reference: a function already bound to the object taxService.
[A2]
[100000, 250000]
385000

Function references are often more readable than lambdas when the function name already makes the intent clear.

6. Returning functions and closures

6.1. Functions that return functions

Higher-order functions can also return a function. This is useful when you want to create behavior based on configuration:

fun discountFor(level: String): (Long) -> Long {
    val percent = when (level) {
        "GOLD" -> 80
        "SILVER" -> 90
        else -> 100
    }
    return { amount -> amount * percent / 100 }
}

fun main() {
    val goldPrice = discountFor("GOLD")
    val normalPrice = discountFor("NONE")

    println(goldPrice(500_000))
    println(normalPrice(500_000))
}
400000
500000

The returned lambda still “remembers” the percent variable, even after discountFor has finished. This mechanism is called a closure.

Note: Don’t write "GOLD" -> { amount -> amount * 80 / 100 } inside the when. At that position, {} is interpreted as a block, not a lambda, so the code won’t compile. Calculate the value first, then return the lambda as shown above. You can also wrap the lambda in parentheses: ({ amount -> ... }).

6.2. Closures can modify variables

Unlike lambdas in Java, Kotlin lambdas are allowed to change a var that they capture:

fun orderIdGenerator(prefix: String): () -> String {
    var next = 1
    return { "$prefix-${next++}" }
}

fun main() {
    val nextId = orderIdGenerator("ORD")
    println(nextId())
    println(nextId())
    println(nextId())
}
ORD-1
ORD-2
ORD-3

The compiler wraps next in a reference object called Ref, so the lambda and the enclosing function share a single value.

Note: Closures that modify shared variables are not thread-safe. If multiple threads call nextId(), you might get duplicate IDs. In that case, use AtomicInteger.

7. Lambdas with receivers

Kotlin has a special form of function type: T.() -> R. Inside this lambda, this is the object of type T. You access its members directly without a prefix. This is the foundation of DSLs like buildString, apply, and the Gradle Kotlin DSL:

class OrderBuilder {
    var id: String = ""
    var customer: String = ""
    var amount: Long = 0

    fun build(): Order = Order(id, customer, amount)
}

fun order(block: OrderBuilder.() -> Unit): Order =
    OrderBuilder().apply(block).build()

fun main() {
    val o = order {
        id = "B1"
        customer = "Carol"
        amount = 120_000
    }
    println(o)
}
Order(id=B1, customer=Carol, amount=120000)

block has type OrderBuilder.() -> Unit. Therefore, inside the lambda, id = "B1" is actually this.id = "B1". apply(block) calls the lambda with the newly created builder as the receiver, then returns the builder itself.

8. inline and return in lambdas

8.1. The cost of lambdas

On the JVM, each lambda is typically compiled into an object. If a lambda captures variables, each call may create a new object. For functions called many times, this cost adds up.

Marking a function as inline solves this problem. The compiler copies the function body and the lambda body directly into the call site, so no objects are created:

inline fun <T> measure(label: String, block: () -> T): T {
    val start = System.nanoTime()
    val result = block()
    println("$label: ${(System.nanoTime() - start) / 1_000} µs")
    return result
}

fun main() {
    val orders = (1..1_000).map { Order("O$it", "C$it", it * 1_000L) }
    val total = measure("Calculate total") { calculateTotal(orders) { it.amount } }
    println(total)
}
Calculate total: 412 µs
500500000

Timing will vary on each machine. filter, map, and forEach in the standard library are all inline.

8.2. Non-local return and labels

In a lambda passed to an inline function, a bare return statement exits the enclosing function, not just the lambda:

fun findFirstLarge(orders: List<Order>, threshold: Long): Order? {
    orders.forEach {
        if (it.amount > threshold) return it   // exit findFirstLarge
    }
    return null
}

fun printPaidOrders(orders: List<Order>) {
    orders.forEach {
        if (it.amount == 0L) return@forEach    // skip this iteration
        println(it.id)
    }
    println("Done")
}
  • return it is a non-local return. It exits findFirstLarge entirely.
  • return@forEach is a labeled return. It only ends the current lambda iteration, like continue.

With a function that is not inline, a bare return in a lambda causes a compiler error. You must use a label.

Note: If an inline function passes a lambda to another context, such as running it in a different thread, mark the parameter as crossinline to forbid non-local returns. If you want to store the lambda in a variable or field, use noinline.

9. Testing higher-order functions

Add test dependencies to pom.xml. Your project should already have kotlin-maven-plugin configured:

<dependencies>
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter</artifactId>
        <version>5.10.2</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.assertj</groupId>
        <artifactId>assertj-core</artifactId>
        <version>3.25.3</version>
        <scope>test</scope>
    </dependency>
</dependencies>

Higher-order functions are very easy to test. You just pass in a simple lambda and check the result:

import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test

class PricingTest {

    private val orders = listOf(
        Order("A1", "Alice", 100_000),
        Order("A2", "Bob", 250_000),
        Order("A3", "Alice", 50_000),
    )

    @Test
    fun `calculateTotal applies the given price function`() {
        assertThat(calculateTotal(orders) { it.amount }).isEqualTo(400_000L)
        assertThat(calculateTotal(orders) { 1L }).isEqualTo(3L)
    }

    @Test
    fun `discountFor returns function matching customer level`() {
        assertThat(discountFor("GOLD")(500_000)).isEqualTo(400_000L)
        assertThat(discountFor("SILVER")(500_000)).isEqualTo(450_000L)
        assertThat(discountFor("UNKNOWN")(500_000)).isEqualTo(500_000L)
    }

    @Test
    fun `generator keeps its own state per closure`() {
        val first = orderIdGenerator("ORD")
        val second = orderIdGenerator("INV")

        assertThat(listOf(first(), first(), second()))
            .containsExactly("ORD-1", "ORD-2", "INV-1")
    }
}

The third test confirms an important point: each call to orderIdGenerator creates its own closure with its own next variable.

10. Common mistakes and when not to use

  • Lambdas that are too long. If the lambda body spans more than a few lines, extract it into a named function and pass it with ::. The code will be easier to read and test.
  • Nesting it without care. If you write orders.map { it.items.filter { it.price > 0 } }, the inner it shadows the outer it. Use explicit parameter names instead.
  • Making everything inline. Only inline small functions with lambda parameters. Inlining large functions bloats the bytecode. An inline function with no lambdas gives little benefit, and the compiler will warn you.
  • Confusing return. A bare return in forEach exits the entire enclosing function. This is a source of subtle bugs. When you only want to skip an element, use return@forEach.
  • Capturing heavy objects. If you store a lambda long-term, such as a listener, it holds references to all variables it captures. This can cause memory leaks, for example keeping an Activity alive on Android.
  • Not needing the abstraction. If there’s only one way to handle something and it never changes, a plain function is usually enough. Don’t add a lambda parameter just to be “flexible for later”.

11. Conclusion

In this article, you’ve seen function types describe the signature of a function. Lambdas and function references are two ways to create function values. Higher-order functions take or return functions to separate the “framework” from the “behavior”. You’ve also learned about closures, lambdas with receivers for building DSLs, and inline to eliminate object allocation along with how non-local returns work.

With these concepts in your toolkit, you’ll find the Kotlin standard library much easier to read. You’ll also know when to write higher-order APIs for your own code.

kotlinlambdahigher-order functionfunctional programming