Kotlin Scope Functions Guide

12 October 2024

Kotlin Best Practices

Scope functions were the first Kotlin feature that made me feel like I was fighting the language instead of using it. Not because they’re complex — let, run, with, apply, also are all simple — but because they overlap in subtle ways that make choosing the “right” one feel arbitrary. I remember staring at code that used run where let would have worked identically, and apply where also would have been clearer, and wondering if there was actually a meaningful distinction or just personal preference.

There is a meaningful distinction, and it comes down to exactly two questions: what does this refer to inside the block? And what does the block return? Once I internalized those two axes, the five scope functions stopped being confusing and started being precise tools for specific situations. Here’s how I think about each one now, including some patterns and mistakes I’ve picked up from years of Android development and code reviews.

The Two Axes: Context Object and Return Value

Every scope function receives an object and executes a lambda on it. The differences are mechanical, but the implications are significant.

Context object — Is the object available as this (receiver) or it (argument)? When it’s this, you can call the object’s methods directly without qualification. When it’s it, you reference the object explicitly, which is clearer when the surrounding scope already has its own this. This matters a lot inside Android classes like Activities and Fragments where this already means something.

Return value — Does the function return the lambda’s result, or the context object itself? Returning the lambda result means the function is useful for transformations and computing new values. Returning the context object means the function is useful for configuration and side effects where you want the original object to flow through.

let — Null Safety and Transformations

let is the scope function I use most, primarily for null-safe operations and value transformations. When called on a nullable type with ?.let, the block only executes if the value is non-null, and inside the block, it is smart-cast to the non-null type. This is one of those small things that eliminates entire categories of if (x != null) boilerplate.

class ProfileViewModel(
    private val userRepository: UserRepository,
    private val analyticsTracker: AnalyticsTracker
) : ViewModel() {

    fun loadProfile(userId: String?) {
        userId?.let { id ->
            viewModelScope.launch {
                val profile = userRepository.getProfile(id)
                profile?.let { analyticsTracker.trackProfileView(it.displayName) }
            }
        }
    }

    fun formatPhoneNumber(raw: String?): String {
        return raw?.let { number ->
            val digits = number.filter { it.isDigit() }
            if (digits.length == 10) {
                "(${digits.take(3)}) ${digits.substring(3, 6)}-${digits.takeLast(4)}"
            } else {
                number
            }
        } ?: "No phone number"
    }
}

I name the it parameter explicitly (id, number) when the block is more than one line or when nested let calls would make it ambiguous. Nested ?.let blocks where every lambda uses it is one of the most common readability mistakes I see in Kotlin code reviews. One level of it is fine. Two levels of it means you should be naming parameters.

The other great use for let is mapping/converting a value inline. Think of it as a lightweight transformation step — you have a value and you want something derived from it. In Android, I use this constantly for things like converting domain models to UI state, formatting display strings, or extracting a specific field from an API response.

apply — Object Configuration

apply is the scope function for configuring objects. It receives the object as this, so you can call methods directly, and it returns the object itself, so you can chain further operations. Whenever I see apply, I know the code is setting up an object — it’s a declaration of configuration, not a computation.

class NotificationHelper(private val context: Context) {

    fun buildOrderNotification(orderId: String, status: String): Notification {
        return NotificationCompat.Builder(context, CHANNEL_ID).apply {
            setContentTitle("Order Update")
            setContentText("Order #$orderId is now $status")
            setSmallIcon(R.drawable.ic_notification)
            setPriority(NotificationCompat.PRIORITY_DEFAULT)
            setAutoCancel(true)
            setContentIntent(
                PendingIntent.getActivity(
                    context, orderId.hashCode(),
                    OrderDetailActivity.createIntent(context, orderId),
                    PendingIntent.FLAG_IMMUTABLE
                )
            )
        }.build()
    }
}

apply shines with builder-style APIs and object initialization. Every setter call inside the block implicitly targets the NotificationCompat.Builder without having to repeat the variable name. The block reads like a configuration declaration — “this notification has this title, this text, this icon.” In real projects, I reach for apply when initializing Intent extras, configuring RecyclerView setups, or setting up DI module bindings.

The tradeoff: because this inside apply refers to the receiver object, you lose easy access to the enclosing this. If you’re inside an Activity and need to reference the Activity’s own properties inside an apply block, you’d need this@MainActivity.property. That’s when also becomes the better choice.

also — Side Effects Without Changing Context

also is apply’s counterpart — it returns the context object (like apply), but provides it as it (like let). This means the enclosing this is preserved, making also ideal for side effects that shouldn’t change the scope. I think of also as “do this on the side without disrupting the flow.”

class UserRepository(
    private val api: UserApi,
    private val cache: UserCache,
    private val logger: Logger
) {
    suspend fun getUser(userId: String): User {
        return api.fetchUser(userId)
            .also { user -> cache.store(user) }
            .also { user -> logger.d("Fetched user: ${user.displayName}") }
    }
}

Each also block performs a side effect (caching, logging) without affecting the return value. The User object flows through unchanged. This is the key distinction from let, which returns the lambda’s result — if you used let here and the lambda returned Unit (from the logger call), you’d lose the User object entirely. I use also for logging, analytics, caching, and any operation where I want to “peek” at a value without transforming it. It’s the equivalent of RxJava’s doOnNext — a transparent observation point in a chain.

run vs with — The Subtle Distinction

run and with both provide the object as this and return the lambda result. The difference is syntactic: run is an extension function (obj.run { }), while with takes the object as an argument (with(obj) { }). But in practice, this syntactic difference leads to very different use cases.

I use with when the object is a known, non-null “subject” of the block — “with this order, build a receipt.” It reads like natural language and signals that the entire block revolves around that one object. with works best when you’re pulling multiple properties from a single object to compute something.

class ReceiptFormatter {

    fun formatReceipt(order: Order): String {
        return with(order) {
            buildString {
                appendLine("Order #$id")
                appendLine("Date: ${createdAt.format(DateTimeFormatter.ISO_DATE)}")
                appendLine("---")
                items.forEach { item ->
                    appendLine("${item.name} x${item.quantity} - $${item.total}")
                }
                appendLine("---")
                appendLine("Subtotal: $$subtotal")
                appendLine("Tax: $$tax")
                appendLine("Total: $$total")
            }
        }
    }
}

I use run when I need null safety (obj?.run { }) or when the call is part of a method chain. with doesn’t support null safety because the object is passed as an argument, not called as a method — you can’t write with(nullableObj?) { }. But beyond null safety, run also reads better when you’re executing a sequence of operations on an object rather than extracting data from it.

class CheckoutManager(private val gateway: PaymentGateway) {

    fun processPayment(orderId: String): PaymentResult {
        return gateway.run {
            val session = createSession(orderId)
            val verified = verifyMerchant(session.merchantId)
            if (verified) authorize(session) else PaymentResult.MerchantNotVerified
        }
    }
}

Here’s my rule of thumb: with is for reading from an object, run is for doing things with an object. with(order) says “I need this order’s data.” gateway.run { } says “I’m going to use this gateway to do something.” Both return the lambda result, but the intent is different.

Scope Functions and Null Handling

Scope functions and nullable types interact in ways that can either clean up your code beautifully or make it worse. The ?.let pattern is the most common, but it’s not always the best choice.

?.let works great for single null checks with transformation. But when you need an else branch, it gets awkward. The ?.let { ... } ?: fallback pattern looks clever, but it has a subtle bug — if the let block itself returns null, the fallback executes even though the original value wasn’t null. I’ve seen this cause real production bugs in code that maps nullable API responses.

// Dangerous: if transform() returns null, fallback runs
val result = apiResponse?.let { transform(it) } ?: fallback()

// Safer: plain if-else when you need both branches
val result = if (apiResponse != null) {
    transform(apiResponse)
} else {
    fallback()
}

?.run is useful when you want null-safe execution with this access — like calling multiple methods on a nullable object without repeating the safe-call operator. I use this a lot with nullable database results or optional configuration objects. And ?.apply is great for conditionally configuring something — “if this intent extra exists, configure the fragment with it.”

IMO, the rule is simple: use scope functions for null checks when there’s no else branch. The moment you need ?: defaultValue and the lambda could return null, switch to a plain if check. It’s boring but correct.

Chaining Scope Functions

Chaining scope functions is powerful but easy to overdo. A well-placed two-level chain can make code incredibly expressive. Three levels deep and you’re writing code that nobody — including future you — will want to debug.

class OrderService(
    private val repository: OrderRepository,
    private val notifier: NotificationService,
    private val logger: Logger
) {
    fun submitOrder(cart: ShoppingCart): Order {
        return cart.let { activeCart ->
            Order.fromCart(activeCart)
        }.apply {
            status = OrderStatus.SUBMITTED
            submittedAt = Instant.now()
        }.also { order ->
            repository.save(order)
            notifier.sendConfirmation(order.userId, order.id)
            logger.info("Order ${order.id} submitted")
        }
    }
}

This chain reads naturally: transform the cart into an order (let), configure it (apply), then perform side effects (also). Each scope function signals a different phase. But I cap it at two levels of nesting as an absolute maximum. The chain above is flat — each function operates on the result of the previous one, which is fine. What kills readability is nesting scope functions inside each other, because this and it change meaning at every level and you lose track of what refers to what.

takeIf and takeUnless

These are the scope functions people forget about, but they’re incredibly useful for filtering. takeIf returns the object if the predicate is true, or null if it’s false. takeUnless does the opposite. They’re not technically scope functions in the official docs, but they follow the same pattern and compose beautifully with let.

class SearchViewModel : ViewModel() {

    fun executeSearch(query: String?) {
        query
            ?.trim()
            ?.takeIf { it.length >= 3 }
            ?.let { validQuery ->
                viewModelScope.launch {
                    _searchState.value = SearchState.Loading
                    val results = searchRepository.search(validQuery)
                    _searchState.value = SearchState.Success(results)
                }
            }
    }

    fun loadCachedProfile(userId: String): UserProfile? {
        return cache.getProfile(userId)
            ?.takeUnless { it.isExpired() }
            ?.also { logger.d("Cache hit for user $userId") }
    }
}

The takeIf + let combo is perfect for validation chains. Instead of writing nested if statements to validate input, you can express the validation as a pipeline: take the value, filter it, then operate on it. I use takeUnless less often, but it reads beautifully for conditions like takeUnless { it.isExpired() } or takeUnless { it.isEmpty() } where the negative condition is more natural.

Common Mistakes

Returning Unit accidentally. This is probably the most common scope function bug. If the last expression in a run or let block is a function that returns Unit — like println, Log.d, or any void method — that Unit becomes the block’s return value. I’ve seen this cause type mismatches that compile fine with Any but blow up at runtime.

// Bug: returns Unit because println returns Unit
val config = settingsManager.run {
    loadDefaults()
    applyOverrides(environment)
    println("Config loaded") // oops — this is the return value
}

// Fix: put the side effect before the actual return value
val config = settingsManager.run {
    loadDefaults()
    println("Config loaded")
    applyOverrides(environment) // this is now the return value
}

Using let just to avoid a local variable. If you’re writing someExpression.let { doSomething(it) } where a val would be clearer, just use a val. The let version adds a lambda and reduces readability for zero benefit. let should earn its place by providing null safety or a transformation, not just by replacing a variable declaration.

Overusing scope functions in general. Not everything needs a scope function. I’ve reviewed code where a simple if (user != null) { saveUser(user) } was written as user?.let { saveUser(it) } — technically equivalent, but the if version is immediately clear to anyone, including developers coming from Java. Scope functions should make code more readable, not just more “Kotlin-y.”

Scope function inside a scope function with the same context. Using apply inside apply or let inside let without naming parameters creates code where nobody can tell which this or it is being referenced. If you find yourself writing this@OuterClass or renaming nested it parameters, that’s a sign the nesting has gone too far.

Real-World Patterns

Here are patterns I use regularly in production Android code that show scope functions at their best.

ViewModel Initialization

class DashboardViewModel @Inject constructor(
    private val userRepository: UserRepository,
    private val settingsRepository: SettingsRepository,
    savedStateHandle: SavedStateHandle
) : ViewModel() {

    private val userId: String = savedStateHandle
        .get<String>("userId")
        ?: throw IllegalArgumentException("userId required")

    val dashboardState: StateFlow<DashboardState> = combine(
        userRepository.observeUser(userId),
        settingsRepository.observePreferences()
    ) { user, prefs ->
        DashboardState(user, prefs)
    }.stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5000),
        initialValue = DashboardState.Loading
    ).also {
        logger.d("Dashboard state flow initialized for user $userId")
    }
}

Repository with Caching Chain

class ArticleRepository(
    private val api: ArticleApi,
    private val dao: ArticleDao,
    private val mapper: ArticleMapper
) {
    suspend fun getArticle(articleId: String): Article {
        return dao.findById(articleId)
            ?.takeUnless { it.isStale() }
            ?: api.fetchArticle(articleId)
                .let { mapper.toDomain(it) }
                .also { dao.insertOrUpdate(mapper.toEntity(it)) }
    }
}

DI Module Configuration

val networkModule = module {
    single {
        OkHttpClient.Builder().apply {
            connectTimeout(30, TimeUnit.SECONDS)
            readTimeout(30, TimeUnit.SECONDS)
            addInterceptor(get<AuthInterceptor>())
            if (BuildConfig.DEBUG) {
                addInterceptor(get<HttpLoggingInterceptor>())
            }
        }.build()
    }

    single {
        Retrofit.Builder().apply {
            baseUrl(BuildConfig.API_BASE_URL)
            client(get())
            addConverterFactory(get<MoshiConverterFactory>())
        }.build()
    }
}

These patterns share a common thread — each scope function is doing exactly one job and signaling that job through its choice. apply configures, also logs, let transforms, takeUnless filters. When I review code, the scope function choice tells me what the developer intended before I even read the block body. If I see apply, I expect configuration. If I see let, I expect a transformation or null check. When the scope function doesn’t match the pattern, it’s a code smell — either the wrong function was chosen, or the block is doing too many things.

The real insight that made scope functions click for me is this: the choice communicates your intent to the reader, not just to the compiler. All five could technically be used interchangeably in most situations — the code would compile and run correctly. But each one carries a semantic signal that makes code self-documenting. Pick the one that tells the reader what you’re doing, and the right choice stops being arbitrary.

Thank You!