What Scope Functions Are
let ยท run ยท with ยท apply ยท alsoA scope function takes an object, runs a lambda in some context related to it, and returns something โ either the object itself or the lambda's result. All five are just ordinary inline extension functions in the standard library, not language keywords; you could write your own equivalent. They exist purely for readability: chaining several operations on one object without repeating its name at every step, or scoping a temporary computation without polluting the surrounding scope with an extra named variable.
Refers to the object as: Returns: let it (lambda argument) lambda result run this (lambda receiver) lambda result with this (lambda receiver) lambda result apply this (lambda receiver) the object itself also it (lambda argument) the object itself Two axes to remember: - argument (it) vs receiver (this) โ how you refer to the object inside - returns lambda result vs returns the object โ what the call evaluates to
it-based or this-based, and either "returns a new value" or "returns the same object," there are exactly five combinations, and four of the five are actually used (there's no this-based, argument-taking, object-returning combination โ that gap is filled by also plus a receiver-style call being redundant with apply).let
it ยท returns lambda resultlet refers to the object as it and returns whatever the lambda's last expression evaluates to. It's most at home paired with the null-safety operator ?. โ running a block only when a nullable value isn't null โ and for transforming a value into something else inline, without a separate named intermediate variable.
val name: String? = getNameFromCache() // Runs only if name isn't null; `it` refers to the non-null String name?.let { println("Welcome, $it") } // let's return value is the lambda's last expression โ // useful for transforming inline, without a named temp variable val length: Int? = name?.let { it.trim().length } // Also common: scoping a value to just the lines that need it val greeting = computeUserName().let { userName -> "Hello, $userName!" }
run
this ยท returns lambda resultrun is let's receiver-style twin: same return-the-lambda-result behavior, but the object is referred to as this (implicit, usually), so its own members are called without a qualifier. There's also a non-extension form, run { } with no receiver at all, useful purely as a scoped block that evaluates to a value.
class Connection { var host: String = "" var port: Int = 0 fun isValid() = host.isNotBlank() && port > 0 } // this is implicit: host/port/isValid() called directly, no qualifier val connectionIsReady = Connection().run { host = "localhost" port = 5432 isValid() // last expression: run's return value } println(connectionIsReady) // true // No-receiver run: just a scoped block that evaluates to a value val config = run { val env = System.getenv("ENV") ?: "dev" "config-$env.json" }
with
this ยท returns lambda result ยท not an extensionwith is functionally identical to run โ receiver as this, returns the lambda's result โ but it's called differently: with(obj) { ... } instead of obj.run { ... }, because with is a plain top-level function taking the object as a parameter, not an extension function on it. It reads best when the object already exists as a variable and you're not chaining off a call that just produced it.
val sb = StringBuilder() // with(sb) reads naturally: "with sb, do the following" val result = with(sb) { append("Hello") append(", ") append("World") toString() // last expression: with's return value } println(result) // "Hello, World"
with isn't an extension function, it doesn't chain off a safe call (?.with isn't valid syntax) and can't be used at the end of a fluent call chain. That's the main practical reason to reach for run instead when the receiver is the result of a preceding expression, like ?.run { ... } for a nullable value.apply
this ยท returns the object ยท configurationapply refers to the object as this, same as run, but always returns the object itself regardless of the lambda's last expression. This makes it the natural fit for object configuration: build or mutate something, then get the same object back to continue using โ the classic replacement for a fluent builder pattern.
class TextView { var text: String = "" var textSize: Int = 12 var color: String = "black" } // apply returns the TextView itself, configured โ assignable directly val label = TextView().apply { text = "Hello" textSize = 18 color = "blue" } // label is a TextView, fully configured in one expression // Common at construction time, avoiding a separate configure step val list = mutableListOf(1, 2, 3).apply { add(4) removeAt(0) }
also
it ยท returns the object ยท side effectsalso is apply's it-based twin: still returns the object unchanged, but refers to it as it rather than an implicit receiver. That makes it the right choice when the block is a genuine side effect โ logging, validation, a debug print โ where writing the object's name explicitly (via it) makes the side-effecting call clearer than an implicit this would.
val numbers = mutableListOf(1, 2, 3) .also { println("Initial: $it") } // logs, then passes the list along unchanged .apply { add(4) } .also { println("After add: $it") } // Validation as a side effect, still returning the original value fun createUser(name: String): User = User(name).also { require(it.name.isNotBlank()) { "name can't be blank" } }
Decision Table
which one, when| Situation | Function | Why |
|---|---|---|
| Null-check + act on a nullable value | let | Pairs with ?.; it is non-null smart-cast inside |
| Transform a value inline, no temp variable | let | Returns the lambda's result |
| Configure a newly-created object, keep it | apply | Returns the object itself; this-based for terse member access |
| Run several calls on an existing variable | with | Reads as "with X, do Y"; not chainable off ?. |
| Initialize + compute a derived result together | run | this-based access, but returns the computed result, not the object |
| Side effect (log, validate) mid-chain | also | it makes the side-effecting call's subject explicit |
Idiomatic Uses & Abuses
nesting ยท overuse ยท readabilityits from different scope functions shadow each other exactly like nested lambdas do (see Gotchas). If a block needs more than one level of scope-function nesting, naming the parameter explicitly at each level (or breaking the chain into named intermediate variables) almost always reads better than leaning further into implicit it/this.// Overused: let adds nothing here except an extra indirection layer val x = computeValue().let { it + 1 } // Clearer: just a plain expression, no scope function needed at all val x2 = computeValue() + 1 // Justified: genuinely null-safe transformation, let earns its place val length = maybeString?.let { it.trim().length } ?: 0
Quick Reference
Syntax cheat-sheet| Function | Object reference | Returns |
|---|---|---|
| let | it | Lambda result |
| run (extension) | this | Lambda result |
| run (no receiver) | n/a | Lambda result โ just a scoped block |
| with(obj) { } | this | Lambda result โ not an extension function |
| apply | this | The object itself |
| also | it | The object itself |