PCSalt
YouTube GitHub
Back to Kotlin
Kotlin · 3 min read

Kotlin Context Receivers & Scope Functions Deep Dive

Master Kotlin's scope functions (let, run, with, apply, also) and understand context receivers — when to use each, common patterns, and real-world examples.


Kotlin’s scope functions — let, run, with, apply, also — are the first thing that confuses developers coming from Java. They all look similar, they all take lambdas, and choosing the wrong one makes code harder to read than writing nothing at all.

This post breaks down each scope function with clear rules for when to use which, then covers context receivers — a newer Kotlin feature for passing implicit context.

The five scope functions

Every scope function does two things:

  1. Executes a lambda on an object
  2. Returns something

They differ in how you access the object (this vs it) and what they return (the object vs the lambda result).

FunctionObject referenceReturn valueUse case
letitLambda resultNull checks, transformations
runthisLambda resultObject configuration + compute
withthisLambda resultGrouping calls on an object
applythisThe objectObject configuration (builder)
alsoitThe objectSide effects (logging, validation)

let — Null checks and transformations

let passes the object as it and returns the lambda result.

Null-safe operations

The most common use — execute a block only if the value is non-null:

val name: String? = getUserName()

name?.let { nonNullName ->
    println("Hello, $nonNullName")
    saveGreeting(nonNullName)
}

Without let, you’d need an if check:

if (name != null) {
    println("Hello, $name")
    saveGreeting(name)
}

Both work. let is more idiomatic when you’re chaining or when the variable might be reassigned between the null check and usage (smart cast won’t work on mutable properties).

Transforming a value

val length = "Hello, Kotlin".let { it.length }
println(length) // 13

// Chaining transformations
val result = fetchUser()
    ?.let { formatDisplayName(it) }
    ?.let { "Welcome, $it" }

When NOT to use let

// Don't do this — just call the function directly
user.let { saveUser(it) }

// Do this
saveUser(user)

If let doesn’t add null safety or transformation, it’s noise.

run — Configure and compute

run accesses the object as this and returns the lambda result:

val greeting = "Kotlin".run {
    "Hello, $this! Length: $length"
}
println(greeting) // Hello, Kotlin! Length: 6

Useful when you need to configure an object and compute a result:

val result = StringBuilder().run {
    append("Hello")
    append(", ")
    append("World")
    toString() // returns this
}
println(result) // Hello, World

Non-extension run

run can also be used without a receiver — it’s just a scoped block:

val hexColor = run {
    val red = 255
    val green = 128
    val blue = 0
    String.format("#%02x%02x%02x", red, green, blue)
}

This scopes temporary variables without polluting the outer scope.

with — Group operations on an object

with takes the object as a parameter (not an extension function):

val user = User("Alice", "[email protected]")

val info = with(user) {
    println(name)   // this.name
    println(email)  // this.email
    "$name ($email)" // returns this lambda result
}

Use with when you have a non-null object and want to call multiple methods on it:

with(binding) {
    tvTitle.text = article.title
    tvAuthor.text = article.author
    tvDate.text = formatDate(article.publishedAt)
    ivCover.load(article.imageUrl)
}

The difference from apply: with returns the lambda result, apply returns the object. Use with when you don’t need the object back.

apply — Configure and return the object

apply accesses the object as this and returns the object itself:

val user = User().apply {
    name = "Alice"
    email = "[email protected]"
    age = 30
}

This is the builder pattern — configure properties and get the configured object back. Common with Android Views:

val textView = TextView(context).apply {
    text = "Hello"
    textSize = 16f
    setTextColor(Color.BLACK)
    setPadding(16, 8, 16, 8)
}

And with Intent/Bundle:

val intent = Intent(this, DetailActivity::class.java).apply {
    putExtra("ITEM_ID", itemId)
    putExtra("ITEM_NAME", itemName)
    addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
}

apply always returns the object, so you can chain further or assign directly.

also — Side effects

also passes the object as it and returns the object:

val users = fetchUsers()
    .also { println("Fetched ${it.size} users") }
    .filter { it.active }
    .also { println("${it.size} active users") }
    .sortedBy { it.name }

Use also for side effects that don’t modify the object — logging, debugging, validation:

fun createUser(name: String, email: String): User {
    return User(name = name, email = email)
        .also { require(it.email.contains("@")) { "Invalid email" } }
        .also { logger.info("Created user: ${it.name}") }
}

Because also returns the original object, the chain continues unchanged.

Decision flowchart

Do you need to configure an object and return it?
  → apply (this, returns object)

Do you need a side effect (logging, validation) without changing the chain?
  → also (it, returns object)

Do you need to transform a nullable value?
  → let (it, returns lambda result)

Do you need to call multiple methods and return a computed result?
  → run (this, returns lambda result) or with (this, returns lambda result)

Do you have a non-null object and want to group calls?
  → with (non-extension, this)

Common patterns

Null-safe chain

val displayName = user?.name
    ?.let { it.trim() }
    ?.takeIf { it.isNotEmpty() }
    ?: "Anonymous"

Configure + validate + log

val config = ServerConfig().apply {
    host = "0.0.0.0"
    port = 8080
    maxConnections = 100
}.also {
    require(it.port in 1..65535) { "Invalid port: ${it.port}" }
    logger.info("Server config: ${it.host}:${it.port}")
}

Resource management

val content = File("data.txt")
    .takeIf { it.exists() }
    ?.let { it.readText() }
    ?: "default content"

Context receivers

Context receivers (stabilizing in Kotlin 2.x) let you declare that a function requires certain context without passing it explicitly.

The problem

Suppose you have logging:

class OrderService(private val logger: Logger) {
    fun placeOrder(order: Order) {
        logger.info("Placing order ${order.id}")
        // ...
    }
}

Every class that logs needs a Logger parameter. Every class that uses a transaction needs a TransactionContext. Parameters pile up.

The solution

With context receivers, you declare the required context:

context(Logger)
fun placeOrder(order: Order) {
    info("Placing order ${order.id}") // Logger methods available directly
}

The caller must provide a Logger in scope:

with(logger) {
    placeOrder(order) // Logger is in context
}

Practical: Transaction context

interface TransactionScope {
    suspend fun <T> execute(block: suspend () -> T): T
    suspend fun rollback()
}

context(TransactionScope)
suspend fun transferMoney(from: Account, to: Account, amount: Double) {
    execute {
        from.debit(amount)
        to.credit(amount)
    }
}

The function requires a TransactionScope in context but doesn’t take it as a parameter. This keeps the function signature focused on business parameters.

Multiple contexts

context(Logger, TransactionScope)
suspend fun processOrder(order: Order) {
    info("Processing order ${order.id}")
    execute {
        // save order
    }
}

Both Logger and TransactionScope methods are available without parameters.

When to use context receivers

  • Cross-cutting concerns: logging, transactions, authorization
  • DSL builders: providing implicit context for nested blocks
  • When a function conceptually needs a “capability” rather than a “parameter”

When NOT to use context receivers

  • When explicit parameters make the code clearer
  • For core business dependencies (use constructor injection instead)
  • When only one or two functions need the context (just pass the parameter)

Context receivers are still being stabilized. For production code today, constructor injection and scope functions cover most use cases. But context receivers are worth understanding — they’re the direction Kotlin is headed for implicit context passing.

Scope functions anti-patterns

Nesting scope functions

// Hard to read — which this/it refers to what?
user?.let { u ->
    u.address?.let { addr ->
        addr.city?.let { city ->
            println(city)
        }
    }
}

// Better — use safe calls directly
val city = user?.address?.city
if (city != null) {
    println(city)
}

Using apply when the return value isn’t needed

// Misleading — apply returns the object, but we don't use it
user.apply {
    saveToDatabase(this)
}

// Clearer — use run or just call the function
saveToDatabase(user)

Overusing let for non-null values

// Pointless — name is already non-null
val name: String = "Alice"
name.let { println(it) }

// Just do this
println(name)

Summary

FunctionReferenceReturnsUse for
letitLambda resultNull checks, transforms
runthisLambda resultConfigure + compute
withthisLambda resultGroup calls
applythisObjectBuilder pattern
alsoitObjectSide effects

Start with apply for configuration, let for null safety, and also for logging. Add run and with as you get comfortable. Context receivers will simplify cross-cutting concerns once they stabilize — worth watching as Kotlin evolves.