Kotlin’s five scope functions immediately invoke a lambda with an object as context. They differ in whether the block calls that object this or receives it as an argument, and whether the whole expression returns the object or the lambda result.
The function name doesn’t constrain side effects, and nested implicit receivers can shadow one another. Rules of thumb such as “let handles nulls” and “also logs” aren’t enough for code review.
Decide what the next link needs returned, then choose between this and an explicit parameter for clarity. Write ?. for nullable receivers, and give nested receivers names or labels.
What it is and why it exists
A scope function
is one of a group of inline functions in the Kotlin standard library that take an object and a lambda.
They supply that object as temporary context while invoking the lambda, so a small group of adjacent operations needn’t repeat its name.
The five functions are let, run, with, apply, and also.
These functions add no new language capability. You can write the same assignments, calls, and transformations without them. They offer a local form for “configure this object,” “compute a result from this value,” or “observe this value inside a chain.”
Two dimensions determine the semantics.
First, the context object is either this in the lambda or a regular lambda argument. Second, the call returns either that context object or the lambda’s final result.
Once you answer those questions, you don’t have to memorize five similar names.
You’ll see scope functions in object initialization, nullable transformations, collection processing, and builder calls. A short block can keep its main object prominent, but a long chain or several nested blocks hide the current type, receiver, and effects. Plain local variables and sequential statements are still idiomatic Kotlin when they’re clearer.
let has no special null-safety power.
The safe call ?. is what skips an invocation, so value?.run {}, value?.apply {}, and value?.also {} also execute only for a non-null value.
Conversely, nullable.let {} without ?. always runs, and it may still be null.
How it works
Two selection axes
The table is the most reliable place to start.
“Argument” means the lambda receives an ordinary parameter named it by default. “Receiver” means the lambda is a lambda with receiver , whose members are available through implicit this.
| Function | Object inside the block | Whole expression returns | Call form |
|---|---|---|---|
let | argument it | lambda result | extension function |
run | receiver this | lambda result | extension function |
with | receiver this | lambda result | regular function, with(value) {} |
apply | receiver this | context object | extension function |
also | argument it | context object | extension function |
let and also pass the object to a lambda shaped like (T) -> ....
A one-parameter lambda may omit its parameter declaration and use it, or rename it to a domain term such as customer or shipment.
Once a block has other values or nested lambdas, a named parameter is usually easier to inspect than successive uses of it.
run, with, and apply accept a lambda shaped like T.() -> ....
Such a block makes T an implicit receiver, so unqualified property and member calls can resolve against this.
That suits a short block that mainly uses the object’s members, but it can also obscure resolution among locals, outer receivers, and members.
The return controls the next link
let, run, and with return the lambda result.
That result is normally the value of the block’s last expression, which makes these functions useful for transformations, summaries, or a multi-statement expression.
If the block returns null, so does the call; the function doesn’t distinguish “receiver was null” from “calculation returned null.”
apply and also ignore the block’s result and return the original context object.
For a reference type, the next link receives the same instance, not a copy.
The receiver form of apply suits concentrated member configuration. The argument form of also suits passing an object to logging, validation, or registration code while keeping the original value in the chain.
Returning the context object doesn’t mean the block left it unchanged.
also may freely mutate a mutable object through it, and apply may perform I/O. Their names are reading conventions, not an effect system enforced by the compiler.
Review the calls inside the block instead of inferring purity from the function name.
with and the two forms of run
with(value) { ... } isn’t an extension call. It passes value as the first argument to a regular function.
The block always executes, and if value has a nullable type, this may be null inside it.
When null should skip the block, value?.run { ... } usually states the condition more directly.
The standard library also has run { ... } without a context object.
It invokes a () -> R and returns the result, which is useful for introducing a few locals where an expression is required.
It isn’t a sixth object scope function and supplies no implicit this; don’t conflate the two run call shapes.
Extension run and let both return a calculation.
If the object is mainly an argument to several calls, a named let parameter is clearer. If the block mainly invokes its members, the receiver form of run is terser.
You can rewrite either as the other, so choose by readability rather than capability.
Nulls and call placement
Evaluation of value?.let { transform(it) } checks the receiver before deciding whether to call let.
For a null receiver, the lambda doesn’t run and the safe call yields null. For a non-null receiver, the block parameter is narrowed to a non-null type.
The same rule applies to the other extension scope functions.
Safe-call placement changes the boundary.
source?.let { parse(it) }?.also { save(it) } skips also when source is null or parse returns null.
With source?.let { parse(it).also { save(it) } }, whether the inner also runs depends only on the static call form of parse, and its argument may itself be nullable.
An Elvis operator after a nullable result likewise can’t say where the null came from.
If “customer not found” and “customer has no email” require different handling, use explicit branches or a sealed result instead of collapsing both into ?.let { ... } ?: fallback.
A selection order
Choose a function in a fixed order:
- Decide whether the whole expression should return the context object or a new result.
- Decide whether an implicit receiver or a named parameter makes the block easier to read.
- If a null receiver should skip an extension call, put
?.there explicitly. - Check nesting, chain length, and effects; return to local variables if the structure is still unclear.
This order is more dependable than memorizing names by use case.
For example, “used for logging” doesn’t determine also by itself. If the code must return the logging call’s result, let matches the return shape instead.
Trace the types first and the intent words second.
Examples
The four programs progress from a receiver-preserving configuration chain to nullable transformation, result calculation, and nested receivers. Each was compiled with Kotlin 2.4.10 and run on JRE 21; every output block below comes from that execution.
Configure and keep the same object
apply configures Shipment members, then also records an event and emits audit text.
Both calls return the Shipment they received, so the final shipment is still the instance just created.
data class Shipment(
val id: String,
var carrier: String = "",
var insured: Boolean = false,
val events: MutableList<String> = mutableListOf(),
)
fun main() {
val shipment = Shipment("S-104").apply {
carrier = "Rail"
insured = true
}.also { configured ->
configured.events += "configured"
println("audit: ${configured.id}/${configured.carrier}")
}
println(shipment)
}audit: S-104/Rail
Shipment(id=S-104, carrier=Rail, insured=true, events=[configured])Unqualified assignments in the apply block resolve against the Shipment receiver.
The named also parameter makes ownership of the event list obvious.
The output also proves that also isn’t read-only: it returns the original object, but the block still adds an event.
If the audit call can throw, the chain won’t return shipment and later statements won’t run.
A scope function doesn’t isolate a failed effect. The caller’s error policy must decide whether “configured but not audited” is acceptable.
Transform a nullable value with let
The lookup may find no customer, and the email field may also be absent.
Two safe calls guard those boundaries, while the inner let turns a normalized email into the final text.
data class Customer(val id: String, val email: String?)
fun findCustomer(id: String): Customer? = when (id) {
"C-7" -> Customer(id, " [email protected] ")
"C-8" -> Customer(id, null)
else -> null
}
fun contactLine(id: String): String =
findCustomer(id)?.let { customer ->
customer.email
?.trim()
?.lowercase()
?.let { email -> "${customer.id} <$email>" }
} ?: "no contact"
fun main() {
println(contactLine("C-7"))
println(contactLine("C-8"))
println(contactLine("C-9"))
}C-7 <[email protected]>
no contact
no contactC-8 and C-9 produce the same text because both paths make the left side of Elvis null.
That is intentional for this function’s contract. If callers must distinguish a missing customer from a missing email, preserve both outcomes instead of adding more let calls.
The code explicitly names customer and email.
If both levels used it, the inner parameter would shadow the outer one, making it easy for a string template to refer to the wrong object.
Compute results with run and with
Extension run validates and sums values on the invoice receiver, returning the final Int.
with then supplies that invoice as a receiver and returns a formatted string.
data class Invoice(val number: String, val lineCents: List<Int>)
fun Invoice.totalCents(): Int = run {
require(lineCents.isNotEmpty()) { "invoice must have lines" }
lineCents.sum()
}
fun main() {
val invoice = Invoice("INV-9", listOf(450, 325, 125))
val summary = with(invoice) {
"$number: ${lineCents.size} lines, ${totalCents()}c"
}
println(summary)
}INV-9: 3 lines, 900cBoth functions return lambda results here, so totalCents() is an Int and summary is a String.
If run were mistakenly changed to apply, the extension would try to return an Invoice rather than its declared Int, and the compiler would report a type mismatch.
with works well when you already have a non-null object, want to group member reads, and need a result.
It provides no null short-circuit, so don’t rewrite invoice?.run as with(invoice) merely for stylistic symmetry.
Label nested receivers
The outer apply receiver is a Form, while the inner run receiver is a Profile; both have city.
Labels make both assignment target and source explicit so the nearest receiver can’t silently take over an unqualified name.
data class Profile(val city: String)
class Form {
var city: String = "unset"
fun render(): String = "city=$city"
}
fun buildForm(profile: Profile): Form =
Form().apply formScope@{
profile.run profileScope@{
this@formScope.city = this@profileScope.city
}
}
fun main() {
println(buildForm(Profile("Paris")).render())
}city=ParisWithout labels, bare city in the inner block first encounters the nearest Profile receiver.
Its property is a val, so one mistaken form might fail to compile. If both objects expose writable members with the same name, the more dangerous outcome is code that compiles and updates the wrong object.
Production code can often remove this nesting with a local variable or named helper. Labels fit short blocks where the receiver relationship itself matters; they shouldn’t become an excuse to preserve several layers of scope functions.
Pitfalls
Fix: write the input and output static type of every link, then filter functions by return behavior first. After the program compiles, still verify identity and effects because the same type doesn’t imply the same value.
Fix: test a null receiver and mark the exact layer guarded by each safe call. When the block intentionally accepts null, keep the ordinary dot and give its argument a name that doesn’t imply the invocation is skipped.
Fix: prefer local variables or named functions. When nesting carries real structural meaning, label receivers, name ordinary parameters, and write this@label explicitly at assignments.
Fix: put non-repeatable I/O in a named statement with an explicit error policy. If also remains, document exception propagation and idempotency, then test effect counts under repeated calls and injected failures.
Fix: merge null paths only when callers consider their reasons equivalent. Use explicit branches or a result type when the reason matters. Name the intermediate result and declare its type once a chain performs more than one substantial transformation.
Fix: use return@let, return@run, or another labeled return to finish only the block. Prefer an ordinary condition or named function for complex exit logic. Test both early-match and no-match paths, and identify the return target explicitly.
Receiver resolution and nested scopes
A receiver lambda doesn’t create an arbitrary newly named object.
Its static function type still supplies this, and unqualified members resolve under Kotlin’s declaration and implicit-receiver rules.
Locals from outside the block remain visible, so a property and local with the same name can make the short form conceal where data comes from.
With nested receivers, the nearest applicable implicit receiver generally has higher priority.
An explicit label selects an outer lambda’s this, as in this@formScope; an ordinary lambda argument can simply be named.
If code repeatedly reaches across two receiver layers, separating the scopes is usually easier to maintain than adding more qualifiers.
Member and extension resolution rules still apply. When a scope function is itself an extension function , the dot is call syntax; the function doesn’t inject a member into the receiver type. When several extensions and implicit receivers are visible in one block, imports, declaration location, and static types all affect the candidate set.
DSLs often nest receivers deliberately because the hierarchy is part of their model.
They can use @DslMarker to restrict outer receivers that shouldn’t remain implicitly accessible.
Ordinary application code without that design boundary usually needs only the smaller tool: a local variable.
Inlining, contracts, and control flow
All five functions are inline in the Kotlin 2.4.10 standard library.
Their bodies call block(this), this.block(), or receiver.block(), then choose the block result or context object as the return.
That accounts for the comparison table without treating the function names as special syntax.
Their standard-library implementations also declare a callsInPlace(block, InvocationKind.EXACTLY_ONCE) contract.
The compiler can use that information when analyzing control-flow facts such as initialization inside the block.
The contract describes the call convention; it isn’t runtime surveillance of business effects and doesn’t promise that the function surrounding the scope call runs only once.
Inlining permits some control flow to cross the lambda boundary.
A bare return can exit the named function around the call, while return@let or an explicit label returns only from the current lambda invocation.
Move that code to a non-inline higher-order function and the same bare return may become illegal, so refactors must compile again and cover both control-flow paths.
Don’t infer an unmeasured speed claim from inline.
Inlining may remove some function objects or indirect calls, but it can also increase code at call sites. The backend and surrounding code determine the resulting machine code.
This topic has no reproducible benchmark, so it describes semantics and implementation shape without performance numbers.
Designing a legible chain
A chain stays readable only while its reader can answer “what is the current value?”
apply and also preserve the context value, let and run switch to the block result, and with begins a result computation from an object in parentheses.
Writing the static type of every step on paper or in a review comment often exposes a mistaken substitution immediately.
A scope-function boundary creates no transaction, lock, or resource scope. Files opened in the block still need explicit closing, shared-object mutation still needs synchronization, and remote calls still need timeout and retry rules. “Scope” describes local name access here; it doesn’t mean resource lifetime is automatically controlled.
Context-returning chains are especially good at hiding partial mutation.
If the first two assignments in apply succeed and the third throws, the object may already have changed even though the whole expression never returned normally.
For all-or-nothing configuration, validate first, construct an immutable value, or put the commit inside a type that can preserve its invariant.
A public API shouldn’t make callers reverse-engineer an internal sequence of scope functions.
Repeated transformations deserve a named function, complex construction deserves validation in build(), and a flow with several failure reasons deserves an explicit result type.
Scope functions should support a clear structure, not substitute for one.
Further reading
4 questions · 2 predict-the-output · 1 spot-the-bug