Skip to content
ExponaBLOG
MODERN ANDROID & KOTLIN · DAY 009 / 100

Scope functions: let, apply, also, run, with in practice

When to use each.

Expona Admin7 min read

Why scope functions matter

Kotlin's scope functions are among the language's most powerful tools for writing concise, readable code. Yet they're also among the most misused. When to use let, apply, also, run, and with isn't always obvious, and choosing the wrong one can make your codebase harder to maintain. This article covers scope functions: let, apply, also, run, with in practice—showing you exactly when each shines and when to step back.

The core idea is simple: scope functions take an object, give you a temporary scope to work with it, and then return something. The differences lie in what they return, how they reference the object, and what semantic intent they communicate to the next developer reading your code.

let for null checks and transformations

let is your go-to when you need to transform an object or safely handle nullable values. It executes a lambda in a new scope, passes the object as a parameter (called it by convention), and returns the lambda's result.

kotlin
val user: User? = fetchUser()
val userName: String? = user?.let { it.name.uppercase() }
// If user is null, userName is also null. No NPE.

This is cleaner than an if-not-null check. But let shines even more when chaining transformations.

kotlin
val result: Int? = getUserId()
    ?.let { id -> fetchUserDetails(id) }
    ?.let { details -> details.score * 2 }
    ?.let { score -> persistScore(score) }
// Each step only runs if the previous one didn't return null

In a production e-commerce system, we've used let extensively for API response handling. When a customer searches for a product, the API might return null, a valid product, or an error. Chaining lets keeps the logic flat and readable:

kotlin
private fun handleSearchResult(response: ApiResponse<Product>) {
    response.data
        ?.let { product -> validateInventory(product) }
        ?.let { validProduct -> addToRecommendations(validProduct) }
        ?.let { _ -> showSuccess("Product ready") }
        ?: showError("Product unavailable")
}

Notice the semantic clarity: let says "I'm transforming this value through a pipeline." Each step depends on the previous one succeeding. This reads like a conversation with the next developer: "Get the product, validate it, add it, then show success—or fail silently."

apply for configuring objects

apply runs a lambda on an object, ignores the lambda's return value, and returns the object itself. It's perfect for configuration and builder-like patterns. The object is referenced as this, so you can skip the receiver name.

kotlin
val button = Button(context).apply {
    text = "Submit"
    setTextColor(Color.WHITE)
    setBackgroundColor(Color.BLUE)
    setOnClickListener { submitForm() }
}

Compare that to the pre-Kotlin style:

kotlin
val button = Button(context)
button.text = "Submit"
button.setTextColor(Color.WHITE)
button.setBackgroundColor(Color.BLUE)
button.setOnClickListener { submitForm() }

With apply, it's clear you're setting up an object as a unit. You return the fully configured object, so it chains naturally.

In a SaaS backend, we've used apply for configuring complex data transfer objects:

kotlin
fun createSubscriptionOrder(userId: String, tier: String) = Order().apply {
    this.userId = userId
    this.tier = tier
    this.createdAt = Clock.systemUTC().instant()
    this.status = OrderStatus.PENDING
    this.metadata = mapOf("source" to "mobile_app")
}

This is more idiomatic than building a constructor with seven parameters. It reads as "create an Order and set these properties"—exactly what you're doing.

also for side effects like logging

also is like let, but it returns the original object instead of the lambda's result. Use it when you want a side effect without changing the value. The object is referenced as it.

kotlin
val user = fetchUser()
    .also { logger.info("Fetched user: ${it.id}") }
    .also { analytics.trackUserLoad(it) }

The key difference from let is semantic. also says "do something on the side, but pass the value through unchanged." In a ride-hailing system, we use also for logging driver locations without interrupting the data flow:

kotlin
fun updateDriverLocation(driverId: String, lat: Double, lon: Double) {
    val location = Location(lat, lon)
        .also { loc -> logger.debug("Driver $driverId at ${loc.toString()}") }
        .also { loc -> metrics.recordLocationUpdate() }
    
    persistLocation(driverId, location)
}

Without also, you'd either log before the assignment or break the flow. also keeps it linear: log, measure, then persist—all in one readable line.

run for combined transformation and context

run is a hybrid: it runs a lambda in a new scope, references the object as this, and returns the lambda's result. Use it when you need to transform an object using its context and then use the result elsewhere.

kotlin
val discount = product.run {
    val basePrice = this.price
    val category = this.category
    when {
        category == "electronics" && basePrice > 1000 -> basePrice * 0.15
        category == "food" -> basePrice * 0.05
        else -> 0.0
    }
}

Here, you're computing a discount based on the product's properties, and run groups that logic tightly. It's clearer than passing the product to a free function and then using the result.

run also works without an object—as a general-purpose scope:

kotlin
val config = run {
    val env = System.getenv("ENV") ?: "dev"
    val dbUrl = when (env) {
        "prod" -> "prod-db-url"
        else -> "dev-db-url"
    }
    AppConfig(dbUrl, env)
}

with for grouped operations on a receiver

with is similar to run, but it doesn't extend the receiver—you pass it as an argument. Use with when you already have an object and want to group operations on it without transforming it.

kotlin
val user = User("alice", "alice@example.com")
with(user) {
    println("Name: $name")
    println("Email: $email")
    notifyUser()
}

This reads as "with this user object, do these things." It's less common in modern Kotlin than apply or let, but it shines when you're extracting and using multiple properties from an object in one block.

Nesting: the silent killer

All five scope functions nest, but deeply nested scopes become unreadable fast. Compare:

kotlin
// Hard to follow
val result = user?.let { u ->
    u.profile?.let { p ->
        p.settings?.let { s ->
            s.theme
        }
    }
}

versus:

kotlin
// Much clearer
val result = user
    ?.let { it.profile }
    ?.let { it.settings }
    ?.let { it.theme }

The second version chains instead of nesting. Each let takes the result of the previous one. Three levels deep is roughly your limit before readability tanks. Beyond that, extract to a named function.

Common mistakes

  • Using apply when you mean let: If you need the result of the lambda, not the original object, use let. apply discards the lambda's return value.
  • Using let for pure configuration: If you're only setting properties and returning the object, apply is more idiomatic than let { obj -> obj.prop = value; obj }.
  • Forgetting nullable semantics: let with ?. is safe, but apply and also don't automatically handle null. Use obj?.apply { ... } or check before calling.
  • Overusing scope functions: Sometimes a simple variable assignment is clearer than chaining three scope functions. Readability beats cleverness.
  • Deep nesting: Extract to a named function once you're three levels deep. Future you will thank you.

Key takeaways

  • let: Transform an object or safely unwrap nullability. Returns the lambda's result.
  • apply: Configure an object. Returns the object itself. Idiomatic for builders and setup.
  • also: Add side effects (logging, metrics) without changing the value. Passes the object through.
  • run: Combine transformation and context. Returns the lambda's result with this as the receiver.
  • with: Group operations on an object you already have. Rarely needed in modern Kotlin.
  • Scope functions improve readability when they communicate intent. Choose based on what you return and what you're doing, not arbitrary style.
  • Avoid deep nesting. Chain or extract to named functions instead.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles