Scope Functions: let, run, with, apply, also
Kotlin’s standard library includes five scope functions — let, run, with, apply, and also — that let you execute a block of code in the context of an object without repeating that object’s name over and over. They exist purely to make code more concise and readable: instead of writing person.name = "Alice" and person.age = 30 on separate lines that each mention person, you can group those calls inside a lambda. The five functions look deceptively similar, but they differ in two important ways: whether the object is referred to as this or it inside the lambda, and whether the function returns the object itself or the lambda’s result. Picking the right one is a small decision that has a surprisingly large effect on how readable your Kotlin code is.
Overview: How Scope Functions Work
Every scope function does the same basic thing: it takes an object, runs a lambda with that object available inside the lambda body, and returns something. What differs between the five functions is how the object is exposed and what gets returned. There are exactly two choices for each:
How the object is exposed inside the lambda: either as the lambda receiver, accessible implicitly via this (so you can call its members without qualifying them), or as the lambda’s single argument, accessible via it.
What the function returns: either the lambda’s result (the value of its last expression), or the original context object itself, unchanged in identity (though possibly mutated).
Combine those two axes and you get four of the five functions. with is the odd one out: it behaves like run (uses this, returns the lambda result) but is a plain top-level function that takes the object as a parameter rather than being called as an extension on the object — that is, you write with(obj) { ... } instead of obj.with { ... }.
Under the hood, all five are declared as inline functions in kotlin.stdlib. Being inline means the compiler does not generate a real function call or allocate a Function object for the lambda at runtime; instead, it splices the lambda’s bytecode directly into the call site at compile time. This is why using a scope function has essentially zero runtime overhead compared to writing the equivalent code by hand with a temporary variable — it is purely a source-level convenience, not an abstraction that costs you performance. The this-based functions (run, with, apply) take a lambda typed as T.() -> R, a function literal with a receiver, which is exactly what lets you drop the receiver name inside the block. The it-based functions (let, also) take an ordinary lambda typed as (T) -> R.
Scope functions are especially valuable combined with nullable types. Calling nullableValue?.let { ... } only executes the block when the value is non-null, and inside the block the compiler smart-casts the parameter to its non-null type — so you get null-safety and a scoped block in one expression, without an explicit if (x != null) check.
Syntax
The five functions follow this general shape:
receiverObject.let { it -> /* use it, returns lambda result */ }
receiverObject.run { this -> /* use this, returns lambda result */ }
with(receiverObject) { this -> /* use this, returns lambda result */ }
receiverObject.apply { this -> /* use this, returns receiverObject */ }
receiverObject.also { it -> /* use it, returns receiverObject */ }
The table below summarizes the differences, which is the single most important thing to memorize about scope functions:
| Function | Object reference | Return value | Extension function? |
|---|---|---|---|
let |
it |
Lambda result | Yes |
run |
this |
Lambda result | Yes |
with |
this |
Lambda result | No (takes object as a parameter) |
apply |
this |
The object itself | Yes |
also |
it |
The object itself | Yes |
A rule of thumb: if the function name ends in a way that suggests “do this and hand me back what I had” (apply, also), you get the object back. If it suggests “run this and give me an answer” (let, run, with), you get the lambda’s result.
Examples
Example 1: let for null-safe transformation
fun main() {
val name: String? = "Kotlin"
val length = name?.let {
println("Processing: $it")
it.length
}
println("Length: $length")
}
Output:
Processing: Kotlin
Length: 6
Because name is a String?, calling ?.let only runs the block if name is non-null; inside the block, the compiler smart-casts the parameter to non-null String, so it.length is safe to call without a null check. The block’s last expression, it.length, becomes the value returned by let, so length is inferred as Int? (nullable, because the whole chain evaluates to null when name is null).
Example 2: apply for object configuration
class Person {
var name: String = ""
var age: Int = 0
}
fun main() {
val person = Person().apply {
name = "Alice"
age = 30
}
println("Name: ${person.name}, Age: ${person.age}")
}
Output:
Name: Alice, Age: 30
Inside apply, the lambda receiver is this (a Person), so name and age refer to the properties on the newly created object without needing a person. prefix. Because apply always returns the receiver, the whole expression evaluates back to the configured Person, which is assigned to person. This builder-style pattern is the single most common use of apply.
Example 3: with, also, and run together
data class Order(val id: Int, val item: String, var quantity: Int, var total: Double)
fun main() {
val order = Order(1, "Widget", 2, 20.0)
val summary = with(order) {
"Order #$id: $quantity x $item = $${total}"
}
println(summary)
val updatedOrder = order.also {
println("Before update: $it")
}.run {
quantity += 1
total = quantity * 10.0
this
}
println("After update: $updatedOrder")
}
Output:
Order #1: 2 x Widget = $20.0
Before update: Order(id=1, item=Widget, quantity=2, total=20.0)
After update: Order(id=1, item=Widget, quantity=3, total=30.0)
This example chains three different scope functions. with(order) { ... } reads several properties without repeating order. and returns a formatted String, which is assigned to summary. Then order.also { ... } runs a logging side effect using it and returns order unchanged, so the chain can continue. Finally .run { ... } is called on that returned object, uses this to mutate quantity and total in place, and explicitly returns this as the block’s last expression so updatedOrder ends up referring to the same, now-mutated Order. Note that order is declared with val, but val only prevents reassigning the order variable to a different object — it does nothing to stop the object’s own var properties from changing, which is exactly what happens here.
How It Works Step by Step
Walking through Example 3 in execution order clarifies what each function actually does at runtime:
Order(1, "Widget", 2, 20.0)constructs the data class instance and binds it toorder.with(order) { ... }is called: the compiler evaluates the lambda withorderas the implicit receiver, builds the string using its current property values, and that string becomes the return value bound tosummary.orderitself is untouched.order.also { ... }runs its lambda withorderpassed in asit, purely for theprintlnside effect, thenalsoreturns the originalorderreference — not a copy..run { ... }is then called directly on that returned reference. Inside,quantity += 1and thetotalreassignment mutate the sameOrderobject thatorderstill points to. The block’s final expression,this, makesrunreturn that same mutated object.updatedOrderandorderare therefore two variable names pointing at the identical, now-mutated object, which is why the finalprintlnshowsquantity=3andtotal=30.0.
Common Mistakes
Mistake 1: Forgetting that let returns the lambda result, not the receiver
A very common trap is using let to “do something to” a mutable collection, forgetting that let returns whatever the last line of its lambda evaluates to — not the object you started with.
fun main() {
val numbers = mutableListOf(1, 2, 3)
val result = numbers.let {
it.add(4)
}
println(result)
}
Output:
true
This compiles fine, but result is not the list — it is the Boolean returned by MutableList.add(), because that is the last expression in the lambda. The fix is also, which always hands back the receiver regardless of what the lambda’s last line evaluates to:
fun main() {
val numbers = mutableListOf(1, 2, 3)
val result = numbers.also {
it.add(4)
}
println(result)
}
Output:
[1, 2, 3, 4]
Now result is the same mutated list, which is almost always what was intended when the goal was “mutate this and keep using it.”
Mistake 2: Reaching for !! instead of a scope function
Many newcomers from Java handle a nullable value by forcing it with !!, which throws a NullPointerException at runtime if the value happens to be null, defeating the entire point of Kotlin’s null-safety system.
fun printUpperCase(text: String?) {
println(text!!.uppercase())
}
fun main() {
val input: String? = null
printUpperCase(input)
}
Output:
Crashes with a NullPointerException (text is null when !! forces it), so nothing is printed to standard output before the program terminates.
Using ?.let together with the elvis operator ?: handles both the null and non-null cases explicitly, with no possibility of a runtime crash:
fun printUpperCase(text: String?) {
text?.let {
println(it.uppercase())
} ?: println("No text provided")
}
fun main() {
val input: String? = null
printUpperCase(input)
}
Output:
No text provided
This version compiles to the same shape of code the JVM would run either way, but it never risks throwing, because the let block only executes when text is proven non-null.
Best Practices
- Use
letfor null-safe chains on a nullable receiver (value?.let { ... }) or to scope a temporary transformation of one value. - Use
applyfor object configuration and builder-style initialization where you want the configured object itself back. - Use
alsofor side effects — logging, validation, debugging prints — that should not change what a chain ultimately evaluates to. - Use
withwhen you already have a non-nullable object and want to group several calls to it without chaining off it, and you need a computed result rather than the object. - Use
runwhen you want boththis-style access and a computed return value, such as combining an object configuration step with a final calculation. - Avoid nesting scope functions of the same kind (an
itinside anotherit); the inner one shadows the outer one and makes code ambiguous to read. Rename lambda parameters explicitly ({ outer -> ... { inner -> ... } }) if nesting is unavoidable. - Do not reach for a scope function just because it is idiomatic-looking — if a plain variable and an explicit statement are clearer, use those instead.
Practice Exercises
- Write a function that takes a nullable
Intparameter and, usingletand the elvis operator, returns double its value if it is non-null, or0if it is null. Test it with both a null and a non-null argument. - Use
applyto build aStringBuilderthat appends three separate lines of text, then print the final combined string. (Hint:StringBuilderhas anappendfunction and its owntoString().) - Given
data class Config(var host: String, var port: Int), write a function that creates aConfig, usesalsoto print it before returning, and usesrunto compute and return a URL string in the formhost:portfrom a separate instance.
Summary
- Kotlin has five scope functions —
let,run,with,apply,also— all thin, inline wrappers for running a lambda against an object. - They differ along two axes: object access (
thisvsit) and return value (lambda result vs the receiver object itself). let: access viait, returns the lambda result — ideal for null-safe chains and one-off transformations.runandwith: access viathis, return the lambda result — ideal when you need a computed value after touching several members.applyandalso: return the receiver itself — ideal for configuration (apply, usesthis) and side effects (also, usesit).- Because they are
inlinefunctions, there is no runtime overhead versus writing the equivalent code by hand with a temporary variable. - A
valholding a mutable object can still have that object’s contents changed through a scope function —valonly fixes the variable’s reference, not the object’s internal state.
