let, run, with, apply, also. Five functions, nearly identical signatures, and a codebase that uses them inconsistently will make every code review slower. The good news: you only need to answer two questions to pick the right one, and once you can answer them without thinking, the rest is muscle memory.
This post builds that muscle memory with Android examples (Intents, Bundles, Paint, ViewBinding, analytics pipelines) and, more importantly, the failure modes that show up in production code when these functions are used on autopilot.
1. The matrix: how you reach the object, and what you get back
Every scope function takes a lambda and runs it against an object. They differ in exactly two ways:
-
Context object: inside the lambda, the object is either
this(the lambda has a receiver) orit(the object is the lambda's single parameter). - Return value: the call returns either the lambda’s last expression or the object itself.
Two memory hooks that survive code review fatigue:
-
applyandalsohand back the receiver. Both begin with "a", think anchor: the object you started with is the object you finish with. -
“Let it be, also it.”
letandalsoare the two that useit. The other three usethis.
Their signatures make the table obvious:
inline fun <T, R> T.let(block: (T) -> R): R // it, returns R
inline fun <T, R> T.run(block: T.() -> R): R // this, returns R
inline fun <T, R> with(receiver: T, block: T.() -> R): R // this, returns R
inline fun <T> T.apply(block: T.() -> Unit): T // this, returns T
inline fun <T> T.also(block: (T) -> Unit): T // it, returns T
Two facts worth internalizing early. First, all five are inline, so there is no lambda allocation and no extra call overhead, and a return inside the lambda returns from the enclosing function (more on that trap later). Second, with is the odd one out: it is not an extension function, so it cannot be used with ?..
When you are unsure, walk the decision flow:
2. apply: configure an object, keep the object
apply shines wherever Android hands you a mutable, setter-heavy object and you want to configure it in the same expression that creates it.
Intents
fun Context.buildShareChooser(body: String, subject: String? = null): Intent {
val send = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, body)
if (subject != null) putExtra(Intent.EXTRA_SUBJECT, subject)
}
return Intent.createChooser(send, getString(R.string.share_via))
}
The Intent is created, configured, and handed to the next call without a throwaway local variable that lingers in scope.
Fragment arguments
class OrderDetailFragment : Fragment(R.layout.fragment_order_detail) {
companion object {
private const val ARG_ORDER_ID = "arg_order_id"
fun newInstance(orderId: String) = OrderDetailFragment().apply {
arguments = Bundle().apply { putString(ARG_ORDER_ID, orderId) }
}
}
}
If you already depend on androidx.core:core-ktx, bundleOf(ARG_ORDER_ID to orderId) says the same thing in one line. Reach for apply when you need conditional puts or calls that are not simple key/value pairs.
Paint in a custom view
class RingProgressView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : View(context, attrs) {
private val trackPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeWidth = resources.displayMetrics.density * 6f
strokeCap = Paint.Cap.ROUND
color = ContextCompat.getColor(context, R.color.ring_track)
}
}
This is the idiomatic way to initialize a Paint once as a field. Doing the same inside onDraw would allocate on every frame regardless of how neatly it reads; apply makes configuration tidy but does not make it free.
Failure mode: the implicit receiver that quietly wins
Inside apply, unqualified names resolve against the innermost receiver first. That is usually what you want, until the inner and outer objects share a member name:
class DetailActivity : AppCompatActivity() {
private fun showHeader() {
// Intent: use the Activity's title as the subtitle.
binding.toolbar.apply {
subtitle = title // BUG: this is Toolbar.title, not Activity.title
}
}
}
Toolbar has its own title, so the right-hand side resolves to the toolbar's title, and the code compiles without a warning. The fix is explicit:
binding.toolbar.apply {
subtitle = this@DetailActivity.title
}
If you catch yourself reaching for a this@Label more than once in a block, the block probably wants to be a plain function or have the outer value captured in a local val first.
3. also: side effects in the middle of a chain
also exists for one job: do something with the value, then pass the same value along. Logging, analytics, assertions, and cache writes all fit.
suspend fun loadCartState(): CartUiState =
cartRepository.fetchCart()
.also { Timber.d("Cart loaded: %d lines", it.lines.size) }
.also { analytics.track(CartViewed(lineCount = it.lines.size)) }
.let { cart -> cart.toUiState(currency = currencyFormatter) }
Reading top to bottom, you see the data path (fetchCart → toUiState) with the observers clearly subordinate. Compare that with a version that stores val cart = ... and sprinkles statements between lines: same behavior, but the data flow is harder to follow.
Failure mode: an observer that can break the pipeline
also runs inline in the same call stack. If analytics.track throws, your cart never reaches the UI:
.also { cart ->
runCatching { analytics.track(CartViewed(cart.lines.size)) }
.onFailure { Timber.w(it, "analytics failed") }
}
Telemetry should never be able to fail a user-visible operation. If it can throw, guard it. (Note runCatching also swallows CancellationException; inside coroutines, prefer a targeted try/catch that rethrows cancellation.)
Failure mode: also on a Flow runs once, not per item
// Looks like per-emission logging. It is not.
val prices: Flow<Price> = repository.priceStream()
.also { Timber.d("price update") } // runs once, when the Flow is built
also operates on the Flow object, not on the values it will emit later. Use onEach { ... } for per-emission side effects. The same trap applies to LiveData and RxJava streams: also sees the container, not the contents.
Failure mode: mutating the receiver “just for logging”
.also { it.lines.sortBy { line -> line.sku } } // sorts the real list the next stage receives
Because also returns the same instance, any mutation is visible downstream. If a block is meant to observe, keep it read-only, and if it needs to transform, that is let or map.
4. let: null-safe blocks and naming a temporary
Null-safe execution
intent.getStringExtra(EXTRA_DEEP_LINK)?.let { link ->
router.open(link)
}
The block runs only when the value is non-null, and inside it the name is a non-null String. This also solves a classic Android problem: smart casts do not work on mutable properties, but a let copies the value into a stable parameter.
private var listener: OnCheckoutListener? = null
fun finish() {
// listener could be nulled by another thread between check and call
listener?.let { it.onCheckoutFinished() } // or simply: listener?.onCheckoutFinished()
}
Note the comment: for a single call, listener?.onCheckoutFinished() is simpler. Use let when you need the non-null value more than once or want to pass it along.
Scoping a temporary
val priceLabel = lines.maxOfOrNull { it.unitPrice }?.let { highest ->
"From ${currency.format(lines.minOf { l -> l.unitPrice })} to ${currency.format(highest)}"
} ?: getString(R.string.price_unavailable)
Here highest lives only inside the block, so it cannot be accidentally reused later in the function.
Failure mode: ?.let { } ?: fallback is not an if/else
val user = session.currentUser
user?.let { profileCache.lookup(it.id) } ?: showLoginScreen()
The programmer’s intent: “if there is a user, use the cache, otherwise show login.” The actual behavior: if there is a user and lookup returns null, the elvis branch also fires, and a logged-in user is thrown to the login screen. The elvis operator reacts to the result of the block, not to whether the receiver was null. Use an explicit branch when the block can legitimately produce null or when the two paths are side effects:
if (user != null) {
profileCache.lookup(user.id)?.let(::render)
} else {
showLoginScreen()
}
Failure mode: a let that returns Unit you did not mean to use
val saved = draft?.let { repository.save(it) } // saved: Unit?, not the saved entity
If save returns Unit, saved is just Unit?. Compilers will not complain; readers will.
5. run and with: compute a result from an object
Both give you this and return the lambda's last expression. Choose by how the call site reads.
run: extension form, works with ?.
val badge: Badge = notification.run {
when {
isMuted -> Badge.None
unreadCount > 99 -> Badge.Text("99+")
else -> Badge.Count(unreadCount)
}
}
Several properties of one object feed a single computed result, so this lets you drop the repeated notification. prefix.
with: when you already hold a non-null object
private fun bind(order: Order) = with(binding) {
orderTitle.text = getString(R.string.order_number, order.id)
itemCount.text = resources.getQuantityString(R.plurals.items, order.lines.size, order.lines.size)
totalPrice.text = priceFormatter.format(order.total)
reorderButton.isEnabled = order.canReorder
}
with(binding) removes binding. from every line of a view-binding block. Notice this one is used purely for its side effects, so the lambda result is Unit. Many teams prefer apply for that pattern; the value of with is mostly readability when you touch many members.
run without a receiver: a block that produces a value
import kotlin.time.Duration.Companion.milliseconds
val retryPolicy = run {
val base = remoteConfig.getLong("retry_base_ms")
val max = remoteConfig.getLong("retry_max_ms")
RetryPolicy(base = base.milliseconds, max = max.milliseconds)
}
This keeps base and max out of the surrounding scope while letting the result be a val.
Failure mode: with on a nullable
with(user) { // user: User?
println(name) // compile error: name is not accessible on a nullable
}
with takes the object as an ordinary argument, so there is no ?. form. Use user?.run { ... }, or, better, resolve the null before the block.
Failure mode: return inside the lambda
Because these are inline, a bare return leaves the enclosing function:
fun resolveTitle(item: Item?): String {
val label = item?.run {
if (isHidden) return "Hidden" // returns from resolveTitle, not from the lambda
name
}
return label ?: "Unknown"
}
Sometimes that is what you want, but it often surprises people. If the intent is to leave only the block, use a labeled return (return@run "Hidden"), or restructure with if/when.
6. Readability pitfalls: nesting and shadowed it
Each function is simple. The trouble starts when they stack.
// Hard to read: three scopes, two different things called `it`.
order?.let {
it.lines.forEach {
analytics.track(LineViewed(orderId = it.id, sku = it.sku)) // which it? Item.
}
}
Inside forEach, it is the OrderLine. The outer it (the Order) is shadowed: nothing in that inner block can reach it, so the orderId = it.id there silently reads the line's id. This compiles cleanly. The Kotlin compiler does not report a shadowed implicit it; the only safety net is the IDE inspection in Android Studio and IntelliJ ("implicit parameter 'it' of enclosing lambda is shadowed"), which does not run in CI builds. If you want it enforced, raise that inspection's severity in your project profile and run IDE inspections in CI. (If you name both parameters the same, for example { x -> ... { x -> ... } }, the compiler does warn about the shadowed name, but that is a different situation.)
The fix costs a few characters:
order?.let { o ->
o.lines.forEach { line ->
analytics.track(LineViewed(orderId = o.id, sku = line.sku))
}
}
Guidelines that hold up in review
-
Name the parameter as soon as you nest. The first
letoralsocan useit; any lambda inside it should be named. - One level per expression. If you want a second level of scope function, it is almost always cleaner as a private function with a descriptive name.
-
Do not chain scope functions just because you can.
x.let { }.also { }.apply { }.run { }forces readers to track context object and return type at every step. -
Choose by return value first. If a block’s job is to configure, prefer
apply; if it observes,also; if it computes,letorrun. Mixing intents in one block (configure and compute) is the signal to split it. -
Prefer a plain statement when it is shorter.
val x = foo(); bar(x)beatsfoo().let { bar(it) }unless you gain null-safety or scoping. -
For real DSLs, use
@DslMarker. When you design builder-style APIs with nested receivers,@DslMarkerstops inner lambdas from silently reaching outer receivers, which is exactly the class of bug in theToolbar.titleexample above.
Cheat sheet in one paragraph
Ask: do I want the object back or a computed result? Object back means apply when configuring (this) and also when observing (it). Computed result means let when the receiver is nullable or deserves a name (it), and run/with when many members feed one value (this). Then check the failure modes: shadowed members, shadowed it, ?.let {} ?: x on nullable results, also on containers like Flow, and a stray return. If the block does not clearly earn its scope function, write the plain code.
🍎 Share Your Kotlin Code with iOS — Without Rewriting Your App
If “we’ll just build it twice, once per platform” describes how your team ships mobile, I’d recommend “Kotlin Multiplatform for Android Developers” — a 22-chapter, hands-on guide that goes past the sample-project demos and follows the shared module all the way to two app store submissions. No iOS or Swift background required.
It walks through the expect/actual mechanism and source set hierarchy, shared architecture, networking with Ktor, persistence with Room KMP and SQLDelight, Koin dependency injection, Flow and StateFlow with Swift interop (SKIE and Swift Export), Compose Multiplatform versus native SwiftUI, navigation, testing across two runtimes, dual-toolchain CI/CD with Linux and macOS runners, publishing a KMP library, and migrating an existing Android app module by module. Everything builds toward one capstone: an offline-first Expense/Budget Tracker, complete with a pending-sync queue, retry/backoff, and a deployment checklist for both stores.
- E-book: Available on Google Play
- Kindle Edition: Available on Amazon
- Topmate: Available on Topmate
- Also available in Paperback & Hardcover.
Originally published on Medium.






Top comments (0)