Skip to content
All posts
September 5, 20266 min read

Kotlin Scope Functions Demystified: let, run, with, apply, also

Learn how Kotlin scope functions reduce boilerplate and improve readability through real-world examples. Covers when to use let, run, with, apply, and also with clear guidance.

AndroidKotlinBest PracticesSolo Dev
Share:

If you've ever written Kotlin code that looks like a chain of nested

code
if (x != null)
checks or repetitive object configuration blocks, you're not alone. Kotlin scope functions solve exactly that problem, but most developers either avoid them entirely or use them inconsistently.

After 13 years of building Android apps at SudarshanTechLabs, I've seen teams waste countless hours on boilerplate that these five functions eliminate in seconds. Here's how to use them correctly.

Understanding the Core Distinction

Kotlin provides five scope functions:

code
let
,
code
run
,
code
with
,
code
apply
, and
code
also
. They all serve the same fundamental purpose — executing a block of code in the context of an object — but differ in two critical ways:

  1. How they reference the receiver (
    code
    it
    vs
    code
    this
    )
  2. What they return (the lambda result vs the receiver object)

Here's the quick reference table I keep pinned in my IDE:

FunctionReceiver ReferenceReturn ValueNull Safety
code
let
code
it
Lambda resultSafe call (
code
?.
)
code
run
code
this
Lambda resultNot null safe
code
with
code
this
Lambda resultNot null safe
code
apply
code
this
Receiver objectNot null safe
code
also
code
it
Receiver objectSafe call (
code
?.
)

The distinction between

code
this
and
code
it
matters more than you think. When you use
code
this
, the receiver becomes an implicit receiver, meaning you can call its methods directly without qualification. With
code
it
, you must explicitly reference it.

let: Null-Safe Transformations

code
let
is your go-to function when working with nullable types. It executes the block only if the receiver is not null, and passes the non-null value as
code
it
.

kotlin
data class User(val name: String, val email: String?)

val user: User? = getUserFromApi()

// Without let - verbose and error-prone
if (user != null) {
    sendWelcomeEmail(user.email)
    logUserActivity(user.name)
}

// With let - concise and null-safe
user?.let {
    sendWelcomeEmail(it.email)
    logUserActivity(it.name)
}

The real power of

code
let
emerges when chaining operations. Each
code
let
call returns the lambda result, allowing you to transform data step by step:

kotlin
val displayName = user
    ?.let { it.name.takeIf { name -> name.isNotBlank() } }
    ?.let { it.uppercase() }
    ?: "Anonymous"

[!TIP] Use

code
let
with safe calls (
code
?.
) for any nullable receiver. It's the only scope function designed specifically for null safety.

run and with: Object Configuration and Computation

code
run
and
code
with
are nearly identical — both use
code
this
as the receiver reference and return the lambda result. The key difference is syntax:
code
run
is called as an extension function, while
code
with
is a regular function that takes the receiver as a parameter.

kotlin
class DatabaseConfig {
    var host: String = ""
    var port: Int = 0
    var timeout: Long = 0
}

// Using run - extension function style
val config = DatabaseConfig().run {
    host = "localhost"
    port = 5432
    timeout = 30000
    // Returns the last expression
    "Connected to $host:$port"
}

// Using with - regular function style
val result = with(DatabaseConfig()) {
    host = "production.db.com"
    port = 3306
    timeout = 60000
    "Connection established"
}

I prefer

code
run
over
code
with
because it reads more naturally in method chains and works seamlessly with nullable receivers when combined with safe calls.

apply and also: Fluent Object Setup

These two functions are unique because they return the receiver object itself, not the lambda result. This makes them perfect for fluent APIs and builder patterns.

code
apply
uses
code
this
as the receiver reference, making it ideal for configuring objects:

kotlin
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
    .apply {
        setContentTitle("New Message")
        setContentText("You have a new message from John")
        setSmallIcon(R.drawable.ic_notification)
        priority = NotificationCompat.PRIORITY_HIGH
        autoCancel = true
    }
    .build()

code
also
uses
code
it
as the receiver reference, making it perfect for side effects like logging or debugging:

kotlin
val userRepository = UserRepository()
    .also { logger.info("UserRepository created: $it") }
    .also { analytics.trackEvent("repository_initialized") }

[!NOTE] Use

code
apply
for configuration (you're setting properties on the object) and
code
also
for observation (you're watching what happens to the object without changing it).

Choosing the Right Function in Practice

Here's my decision framework that I've refined across 22 shipped apps:

  1. Is the receiver nullable? Use
    code
    let
    with
    code
    ?.
  2. Do you need to configure an object? Use
    code
    apply
  3. Do you need side effects without changing the object? Use
    code
    also
  4. Do you need to compute a result from the object? Use
    code
    run
    or
    code
    with

Let me show you a real-world example from my latest app where I use all five together:

kotlin
class UserManager(private val api: ApiService) {
    
    fun processUserSession(token: String?): UserSession? {
        return token
            ?.let { validateToken(it) }           // Null-safe transformation
            ?.also { logger.debug("Token validated: $it") }  // Side effect
            ?.let { fetchUser(it) }               // Another transformation
            ?.apply {                             // Configure the user object
                lastLogin = System.currentTimeMillis()
                isActive = true
            }
            ?.also { saveToCache(it) }            // Side effect
            ?: run {                              // Fallback computation
                logger.warn("Session processing failed")
                null
            }
    }
    
    private fun validateToken(token: String): String = token
    private fun fetchUser(token: String): User = api.getUser(token)
    private fun saveToCache(user: User) { /* ... */ }
}

This pattern handles nullability, logging, configuration, and fallback logic in a single readable chain.

Performance and Readability Trade-offs

Scope functions introduce minimal overhead — they're inline functions, so the compiler eliminates the lambda allocation entirely. However, excessive chaining can hurt readability.

Here's a before/after comparison from refactoring a legacy codebase:

Before (without scope functions):

kotlin
val dialog = AlertDialog.Builder(this)
dialog.setTitle("Confirm Delete")
dialog.setMessage("Are you sure you want to delete this item?")
dialog.setPositiveButton("Delete") { _, _ -> deleteItem() }
dialog.setNegativeButton("Cancel", null)
val alertDialog = dialog.create()
alertDialog.show()

After (with apply):

kotlin
AlertDialog.Builder(this)
    .apply {
        setTitle("Confirm Delete")
        setMessage("Are you sure you want to delete this item?")
        setPositiveButton("Delete") { _, _ -> deleteItem() }
        setNegativeButton("Cancel", null)
    }
    .create()
    .show()

The second version is shorter, but more importantly, it expresses intent clearly: we're configuring a dialog builder, then creating and showing the result.

[!WARNING] Don't force scope functions into every situation. If a simple assignment or method call is clearer, use that instead. Scope functions should enhance readability, not obscure it.

Testing and Debugging Considerations

When writing unit tests, scope functions can make mocking slightly trickier because they create implicit dependencies. However, they also make test code more concise:

kotlin
@Test
fun `user session processes correctly`() {
    val mockApi = mock<ApiService>()
    val mockLogger = mock<Logger>()
    
    val manager = UserManager(mockApi, mockLogger)
    
    val result = manager.processUserSession("valid_token")
        .also { assertNotNull(it) }
        .also { assertEquals("test@example.com", it?.email) }
    
    verify(mockLogger).debug(any())
    verify(mockApi).getUser("valid_token")
}

For debugging, I often insert

code
also
blocks with breakpoints:

kotlin
val processed = data
    .also { debugger.log("Processing started: ${it.size} items") }
    .filter { it.isValid }
    .also { debugger.log("After filtering: ${it.size} items") }
    .map { transform(it) }

This approach lets you inspect intermediate states without modifying the production code flow.

Key Takeaways

  • Use
    code
    let
    with safe calls (
    code
    ?.
    ) for null-safe operations on nullable receivers — it's the only scope function designed for this purpose
  • Choose
    code
    apply
    for object configuration (setting properties) and
    code
    also
    for side effects (logging, debugging) — both return the receiver object
  • Prefer
    code
    run
    over
    code
    with
    for method chaining, and use them when you need to compute a result from an object rather than configure it
  • Remember that scope functions are inline, so they add zero runtime overhead — but don't overuse them to the point where code becomes hard to read
  • When in doubt, follow this simple rule: if you're setting properties, use
    code
    apply
    ; if you're observing behavior, use
    code
    also
    ; if you're transforming data, use
    code
    let
    or
    code
    run
Share:
S

Sudarshan Chaudhari

AI Systems Builder / Product Engineer

Bangkok, Thailand

Solo Android developer with 13+ years in QA, building Android apps, AI automation systems, and developer tools at SudarshanTechLabs.

Stay updated

Get new posts on Android, Kotlin, and solo dev straight to your inbox.

Newsletter preferences

Related Apps

MyFamilyTracker

Real-time family location sharing — Firebase Realtime DB for sub-second propagation, WorkManager + ForegroundService for OS-compliant background collection, geofencing via Google Maps API.

Building something? Available for Android dev and QA consulting.

Work with me

Comments — powered by Giscus

Apps tagged with this