DEV Community

Cover image for Two Questions, Five Lambdas: Kotlin Scope Functions Without the Guesswork
Android Expert
Android Expert

Posted on Originally published at Medium

Two Questions, Five Lambdas: Kotlin Scope Functions Without the Guesswork

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:

  1. Context object: inside the lambda, the object is either this (the lambda has a receiver) or it (the object is the lambda's single parameter).
  2. Return value: the call returns either the lambda’s last expression or the object itself.

scope-matrix

Two memory hooks that survive code review fatigue:

  • apply and also hand 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.” let and also are the two that use it. The other three use this.

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
Enter fullscreen mode Exit fullscreen mode

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:

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.

apply-builder-flow

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))
}
Enter fullscreen mode Exit fullscreen mode

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) }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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.

also-chain

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) }
Enter fullscreen mode Exit fullscreen mode

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") }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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()
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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()
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

If save returns Unit, saved is just Unit?. Compilers will not complain; readers will.

let-null-gate

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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.
    }
}
Enter fullscreen mode Exit fullscreen mode

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.)

nested-shadowing

The fix costs a few characters:

order?.let { o ->
    o.lines.forEach { line ->
        analytics.track(LineViewed(orderId = o.id, sku = line.sku))
    }
}
Enter fullscreen mode Exit fullscreen mode

Guidelines that hold up in review

  • Name the parameter as soon as you nest. The first let or also can use it; 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, let or run. 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) beats foo().let { bar(it) } unless you gain null-safety or scoping.
  • For real DSLs, use @DslMarker. When you design builder-style APIs with nested receivers, @DslMarker stops inner lambdas from silently reaching outer receivers, which is exactly the class of bug in the Toolbar.title example 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.


Originally published on Medium.

Top comments (0)