Scope Functions Explained
KOTLIN › Language Idioms
Kotlin has five *scope functions*: let, run, with, apply, and also. All five do the same basic thing, run a block of code against an object:
val name = person.let { it.name }
Interviewers love asking about them because they look interchangeable but aren't, and picking the right one signals whether you actually understand Kotlin or just memorized syntax. The good news: every one of the five is fully described by just two questions.
1. How does the block refer to the object: as this, or as it? 2. What does the whole expression return: the block's last line, or the object itself?
Answer those two questions for a given function and you know everything you need about it. The rest of this lesson walks that grid, then covers a few related pitfalls and near-relatives that interviewers like to probe once they know you've got the basics.
**Axis 1: this versus it.**
Inside run, with, and apply, the object is the *receiver*, you refer to it as this, and just like inside a class, you can drop this. entirely:
person.apply {
name = "Ada" // means this.name, no prefix needed
}
Inside let and also, the object arrives as a lambda *argument* called it (which you can rename):
person.let { println(it.name) }
person.let { p -> println(p.name) } // same thing, renamed
Rule of thumb: this reads best when you're touching the object's own members a lot, that's configuration. it reads best when the object is a *value* you're passing around or transforming.
**Axis 2: what comes back.**
let, run, and with return whatever the **last expression in the block** evaluates to, the lambda result:
val len: Int = "hello".let { it.length } // len = 5
apply and also ignore the block's result and return the **object itself**. In fact, those two are the only ones of the five that ever return the object, the other three always hand back whatever the block computed:
val s: String = "hello".apply { length } // s = "hello", not 5
A handy mnemonic: **apply and also give the object back**, both start with 'a', both return the receiver. The other three transform.
Now the individual functions, starting with the workhorse: **apply is for configuration**.
It's this-style (bare property access) and returns the object, perfect for "create, set up, keep":
val button = Button(context).apply {
text = "OK"
isEnabled = true
}
// button is the configured Button
Without apply this is three statements and a temporary variable. With it, construction and configuration read as one expression that hands you the finished object. This builder-style setup is the single most common scope-function pattern in Android code.
**also is for side effects that don't interrupt the flow.**
It's it-style and returns the object, so you can slot it into the middle of a chain to log, validate, or stash a value, and the chain carries on as if nothing happened:
val result = loadNumbers()
.also { Log.d(TAG, "loaded ${it.size} items") } // side effect
.filter { it > 0 }
Why it and not this? Two reasons. First, a side effect usually passes the object *to* something, Log.d(TAG, "$it"), and it makes that explicit. Second, and this is the one interviewers probe for: this can collide with an outer this you're already inside. Say you're inside an extension function on Fragment and you apply on some other object, apply's this now refers to that other object, and reaching the Fragment needs an awkward label like this@methodName. also's it never shadows anything, the outer this stays exactly what it was.
So even when you want the object back, reach for also the moment the block is passing the object as an argument, or you're worried about shadowing an outer receiver. Reach for apply when you're setting the object's own properties directly.
**let is for null-safety and transformation.**
Combined with the safe-call operator, ?.let runs the block *only* when the value is non-null, and inside the block it is smart-cast to non-null:
val user: User? = findUser(id)
user?.let {
save(it) // it: User (not User?) in here
}
This replaces the manual guard, if (user != null) { ... }, and it works even where you can't smart-cast at all, like a mutable var property, because let captures the value once into it rather than re-reading the property.
And because let returns the lambda result, it doubles as a transformer. Outside of null-checks, this is the other idiomatic use of let: mapping a value into something else while keeping the intermediate name scoped tightly to the block instead of leaking a throwaway variable into the surrounding function.
val domain: String? = email?.let { it.substringAfter("@") }
If email is null the whole expression is null, otherwise it's the transformed value.
One let pitfall worth knowing: nesting them silently shadows it.
outer.let {
inner.let {
process(it, it) // both 'it' here refer to inner
}
}
The inner block's it hides the outer one completely, there's no way to reach the outer object once you're inside, and the compiler won't warn you about it. The fix is to stop relying on the implicit name and give each lambda parameter an explicit one:
outer.let { o ->
inner.let { i ->
process(o, i) // both clearly accessible
}
}
The same problem applies to also, since it uses it the same way. run and with don't hit it in quite the same form, nesting those shadows this instead, and Kotlin gives you labeled receivers, this@outer, to reach past that. let and also have no such label, because it isn't a receiver, it's just a parameter name, so naming the parameters is the only fix.
Last pair: **run and with group work and compute a result.**
Both are this-style and return the lambda result, they're near-twins. The difference is call shape: run is an extension, so it chains and plays nicely with ?.; with takes the object as an argument instead:
val area = rect?.run { width * height } // null-safe, chains
val area2 = with(rect) { width * height } // reads as a statement
That difference isn't cosmetic. with has no safe-call form at all, there's no with?(obj) { }, so the instant the object might be null, run is the only one of the two that works. Use run when the object might be null or you're already mid-chain; use with when you have a non-null object on hand and want to say "with this thing, do all of the following."
One oddball worth knowing: there's also a **non-extension run**, no object at all, it just runs a block where an expression is needed, handy for scoping a temporary variable that only exists to build one result:
val config = run {
val dir = findConfigDir()
parse(dir.resolve("app.cfg"))
}
Two close relatives of the scope functions, worth knowing even though they're not part of the this/it grid: **takeIf and takeUnless**.
takeIf returns the receiver itself when a predicate is true, and null when it's false. takeUnless is the exact inverse:
val activeUser = user.takeIf { it.isActive } // user, or null if inactive
val staleUser = user.takeUnless { it.isActive } // user, or null if active
They're most useful chained straight into ?.let, turning a condition into a null-check in one line instead of writing an if:
user.takeIf { it.isActive }?.let { activate(it) }
Nothing runs if the predicate fails, since the chain becomes null?.let { }, which is a no-op. Think of takeIf as converting "is this true" into "do I have a non-null value", which is exactly the shape ?.let expects.
All five scope functions, along with takeIf, takeUnless, and most of the collection functions like forEach and map, are declared **inline**. That's a compiler detail with two real consequences.
First, performance: the compiler copies the lambda's body directly into the call site instead of allocating a lambda object at runtime, so wrapping a hot loop in let or also costs nothing extra.
Second, and more visible in interviews: inline permits a **non-local return**, a return written inside the lambda that exits the *enclosing function*, not just the lambda:
fun findFirst(list: List<Int>): String {
list.forEach {
if (it > 5) return "found $it" // returns from findFirst
}
return "not found"
}
That return only compiles because forEach is inline. Write the same thing inside a lambda passed to a non-inline function and the compiler rejects it, a return there can only exit the lambda itself, which Kotlin requires you to spell out as a labeled return like return@forEach.
That's the whole grid. Here it is in one place:
- let, it, returns **lambda result**, best for null-checks, transforms - run, this, returns **lambda result**, best for null-safe grouping, computing from an object - with, this, returns **lambda result**, best for grouping calls on a non-null object - apply, this, returns **object**, best for configuration - also, it, returns **object**, best for side effects in a chain
When you're choosing in real code, ask the two questions in order: *do I want the object back, or a computed result?* Then: *am I setting its members (this) or using it as a value (it)?* The answer lands you on exactly one function. Being able to narrate that choice out loud, and mention the sharp edges, ?.let for null-safety, also to dodge shadowing, run when with can't go null-safe, is precisely what an interviewer is listening for.