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:
- In the lambda, do you access the object via
this(receiver) or viait(parameter)? - 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:
applyandalsoreturn the object, so they’re suited for configuring an object or inserting side effects into a call chain.let,runandwithreturn 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, orwithif the object is definitely notnull. - 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.