Scope functions in Kotlin: let, run, apply, also, with

1. Overview

Kotlin has five functions that look very similar: let, run, with, apply and also. They all execute a block of code on an object, but they differ in how you access that object and what value the function returns. Many people randomly pick a function, and the code still runs, but it becomes harder to read.

This article explains two criteria for distinguishing scope functions and illustrates each function with concrete examples. You’ll then write tests to verify their behavior and see common pitfalls. After reading, you’ll know how to choose the right function for each situation.

2. Two Distinguishing Criteria

Every scope function takes a lambda and calls it on a context object. You just need to answer two questions:

  1. In the lambda, do you access the object via this (receiver) or via it (parameter)?
  2. Does the function return the result of the lambda or return the object itself?
Function Context object Return value Is extension?
let it Lambda result Yes
run this Lambda result Yes (and non-extension version)
with this Lambda result No, object is a parameter
apply this Object itself Yes
also it Object itself Yes

You can remember them quickly in pairs:

  • apply and also return the object, so they’re suited for configuring an object or inserting side effects into a call chain.
  • let, run and with return the result, so they’re suited for transforming an object into a different value.

The examples below use a few simple classes:

data class Customer(val name: String, val email: String?)

class Order(val id: String) {
    val items = mutableListOf<Double>()
    var discountPercent = 0
}

class Invoice {
    var number: String = ""
    var amount: Double = 0.0
    var paid: Boolean = false
}

3. let: Transform and handle nullable values

let passes the object into the lambda under the name it and returns the result of the lambda. The most common use is to combine it with the safe call ?. to run code only when the value is not null:

fun sendWelcome(customer: Customer) {
    customer.email?.let { email ->
        println("Sending welcome email to $email")
    }
}

fun main() {
    sendWelcome(Customer("Alice", "alice@example.com"))
    sendWelcome(Customer("Bob", null))
}
Sending welcome email to alice@example.com

For customer Bob, email is null. The ?. expression stops there and the lambda doesn’t run. Inside the lambda, email has type String, not String?, so you can use it without additional checks. Here we renamed the parameter to email instead of it to make the code more readable.

let is also useful when you want to transform a value without creating an intermediate variable:

val domain = Customer("Alice", "alice@example.com").email
    ?.let { it.substringAfter("@") }
    ?: "unknown"

println(domain) // example.com

4. run: Compute based on the object

run is like let, except the object is accessed via this. Because this can be omitted, you call properties and methods of the object directly:

val order = Order("ORD-42").apply {
    items += listOf(100_000.0, 50_000.0)
    discountPercent = 10
}

val total = order.run {
    val subtotal = items.sum()
    subtotal - subtotal * discountPercent / 100
}

println(total) // 135000.0

In the run block, items and discountPercent are properties of order. The return value is the last expression, which is the amount after the discount.

run also has a non-extension version. You use it to combine a few statements that produce a value, without letting temporary variables leak into the surrounding scope:

val connectionUrl = run {
    val host = System.getenv("DB_HOST") ?: "localhost"
    val port = System.getenv("DB_PORT") ?: "5432"
    "jdbc:postgresql://$host:$port/shop"
}

After this block, host and port no longer exist. Only connectionUrl is retained.

5. with: Group multiple calls on the same object

with works like run, but it’s not an extension. The object is passed as the first parameter:

val summary = with(order) {
    "Order $id: ${items.size} items, $discountPercent% off"
}

println(summary)
Order ORD-42: 2 items, 10% off

Semantically, with(order) { ... } reads like “with this order, do…”. This function is suitable when the object is definitely not null and you need to call multiple members on it. If the object could be null, you should use order?.run { ... }, because with can’t be combined with ?..

6. apply: Configure an object

apply accesses the object via this and returns the object itself. This is the idiomatic way to initialize and configure an object in a single expression:

val invoice = Invoice().apply {
    number = "INV-001"
    amount = 250_000.0
    paid = false
}

println("${invoice.number} - ${invoice.amount}") // INV-001 - 250000.0

The lambda’s result is discarded and apply always returns the Invoice that was just created. You’ll often see apply when working with Java classes that use setters, for instance when configuring a bean in Spring:

@Bean
fun restTemplate(): RestTemplate = RestTemplate().apply {
    requestFactory = SimpleClientHttpRequestFactory().apply {
        setConnectTimeout(2_000)
        setReadTimeout(5_000)
    }
}

7. also: Add side effects without breaking the call chain

also accesses the object via it and also returns the object itself. This function is suited for “side activities” like logging, validating, or calling metrics, when those activities don’t change the main flow:

fun createOrder(id: String): Order =
    Order(id).also { println("Created order ${it.id}") }

fun main() {
    val order = createOrder("ORD-7")
        .also { it.items += 99_000.0 }
        .also { println("Number of items: ${it.items.size}") }
}
Created order ORD-7
Number of items: 1

Because the object is accessed via it rather than this, you can still use this from the outer class without confusion. This is also why also is more readable than apply when the lambda primarily uses the object rather than configures it.

Here’s a small trick: use also to swap two variables:

var a = 1
var b = 2
a = b.also { b = a }
println("$a $b") // 2 1

The expression b.also { ... } returns the old value of b (which is 2), while the lambda assigns b = a (which is 1) before the assignment to a happens.

8. Which function to use?

You can use these rules:

  • Need to run code when a value is not null, or need to transform a value: use ?.let.
  • Need to compute a value from many members of an object: use run, or with if the object is definitely not null.
  • Need to combine a few statements into an expression: use run { } without a receiver.
  • Need to initialize or configure an object: use apply.
  • Need to log, validate, or add side effects while keeping the object: use also.

9. Testing the behavior

To make sure you understand the return values correctly, write a few tests. First, add dependencies to pom.xml:

<dependencies>
    <dependency>
        <groupId>org.jetbrains.kotlin</groupId>
        <artifactId>kotlin-stdlib</artifactId>
        <version>2.0.21</version>
    </dependency>
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter</artifactId>
        <version>5.11.3</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.assertj</groupId>
        <artifactId>assertj-core</artifactId>
        <version>3.26.3</version>
        <scope>test</scope>
    </dependency>
</dependencies>

If your project is Spring Boot 3.x, spring-boot-starter-test already includes JUnit 5 and AssertJ, so you don’t need to declare these two libraries separately.

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

class ScopeFunctionsTest {

    @Test
    fun `apply and also return the object itself`() {
        val invoice = Invoice()

        val fromApply = invoice.apply { amount = 100.0 }
        val fromAlso = invoice.also { it.paid = true }

        assertThat(fromApply).isSameAs(invoice)
        assertThat(fromAlso).isSameAs(invoice)
        assertThat(invoice.amount).isEqualTo(100.0)
        assertThat(invoice.paid).isTrue()
    }

    @Test
    fun `let run with return the lambda result`() {
        val order = Order("ORD-1").apply { items += listOf(10.0, 20.0) }

        val count: Int = order.let { it.items.size }
        val sum: Double = order.run { items.sum() }
        val label: String = with(order) { "Order $id" }

        assertThat(count).isEqualTo(2)
        assertThat(sum).isEqualTo(30.0)
        assertThat(label).isEqualTo("Order ORD-1")
    }

    @Test
    fun `let does not run when value is null`() {
        var called = false
        Customer("Bob", null).email?.let { called = true }

        assertThat(called).isFalse()
    }
}

The first test uses isSameAs to verify that apply and also return the exact same reference, not a copy. The second test declares return types explicitly (Int, Double, String) to clearly show what each function returns. The third test confirms that ?.let skips null values.

10. Common pitfalls

10.1. this gets shadowed in apply

Inside apply, the nearest receiver takes priority. So a property with the same name in an outer class will be shadowed:

class InvoiceFactory(private val amount: Double) {
    fun create() = Invoice().apply {
        amount = amount // assigns Invoice.amount to itself, result is still 0.0
    }
}

You have two ways to fix this. The first is to be explicit about the receiver using this@InvoiceFactory.amount. The second is to switch to also:

fun create() = Invoice().also { it.amount = amount }

10.2. The ?.let { } ?: ... trap

Many people write x?.let { ... } ?: fallback intending it to work like if/else. This pattern is not equivalent. If the lambda returns null, the ?: branch also runs:

val cache = mapOf<String, String>()
val email: String? = "alice@example.com"

email?.let { cache[it] } ?: println("No email")
No email

Here email is not null, but cache[it] returns null, so the message still prints even though it’s incorrect. When you need a clear branching condition, use if (email != null) ... else ....

10.3. Nesting too many levels

When you nest scope functions inside each other, it and this will refer to multiple different objects and readers have to trace back to see what each refers to:

// Hard to read
order.let { o -> o.items.firstOrNull()?.let { price -> price.run { this * 2 } } }

Note: You shouldn’t nest more than one level. If you must nest, use explicit parameter names instead of it. Even better, extract to a local variable or separate function.

10.4. Using scope functions just because you can

The snippet val name = customer.let { it.name } offers no benefit over val name = customer.name. Scope functions are useful when they help you eliminate intermediate variables, handle null, or group configuration code. If they don’t do those things, don’t use them.

11. Conclusion

Kotlin’s five scope functions differ in only two ways: whether the object is accessed via this or it, and whether the function returns the object or the lambda’s result. From there come their respective uses: apply for configuration, also for side effects, let for handling nullable values and transformation, and run and with for computation based on the object. You also need to be careful about this being shadowed, the ?.let ?: trap, and nesting too many levels. Finally, remember that the goal of scope functions is to make code more readable, not shorter at all costs.

kotlinscope-functionsletapplyclean-code