Why Five Scope Functions in Kotlin?
The Kotlin standard library provides five scope functions: let, run, with, apply, and also.
They look similar, so developers often pick one arbitrarily. But receiver reference style and return value differ across
them; the wrong choice obscures intent.
This post clarifies the differences and provides practical criteria for choosing the right function in each situation.
Core Functional Axes
The five scope functions are distinguished by two primary characteristics: how they reference the receiver (this vs
it) and what they return (the lambda result vs the receiver itself).
| Function | Extension? | Receiver Reference | Return Value | Primary Use Case |
|---|---|---|---|---|
| let | Yes | it (or named) |
Lambda Result | Null-safe transformations, scope limiting. |
| run | Yes* | this |
Lambda Result | Configuration + result computation. |
| with | No | this |
Lambda Result | Grouping operations on an existing object. |
| apply | Yes | this |
Receiver | Object configuration (setting properties). |
| also | Yes | it |
Receiver | Side effects (logging, validation). |
*Note: run exists both as an extension function (T.run) and a standalone block.
let: Null-Safe Transformations and Scoping
let is commonly used with the safe-call operator (?.) to execute a block only when the receiver is non-null. It
references the object as it.
data class RawSignupRequest(
val email: String?,
val nickname: String?
)
fun extractEmail(request: RawSignupRequest): String? {
return request.email?.let { raw ->
// Executes only if email is non-null
raw.trim().lowercase().takeIf { it.contains("@") }
}
}
let is also useful for isolating variables within a specific scope to prevent namespace pollution.
fun storeIfAbsent(cache: MutableMap<String, String>, userId: Long, value: String) {
buildKey(userId).let { key ->
if (!cache.containsKey(key)) {
cache[key] = value
}
}
}
Anti-pattern: Avoid deeply nested let blocks, as they make it difficult to track which object it refers to. Use
explicit parameter names or early returns instead.
run: Direct Access and Result Computation
run is similar to let but references the receiver as this. This allows for direct member access, making it feel
more natural for computing a result from multiple properties.
data class ReportConfig(
val title: String,
val maxRows: Int
)
fun summarize(config: ReportConfig): String {
return config.run {
// Direct access to 'title' and 'maxRows' via 'this'
"$title (Limit: $maxRows rows)"
}
}
The non-extension version of run is useful for grouping multiple statements into a single expression.
val hexRegex = run {
val digits = "0-9"
val hexDigits = "A-Fa-f"
Regex("[$digits$hexDigits]+")
}
with: Operating on Existing Objects
with is a non-extension function that takes the receiver as an argument. Use it when the object is already non-null
and you want to perform multiple operations without repeating the object’s name.
fun describeProfile(profile: UserProfile): String {
return with(profile) {
// 'this' is implicit
"User: $nickname, Grade: $grade"
}
}
Unlike run, with does not support safe-call chaining (?.with).
apply: Object Configuration
apply is the standard choice for initializing or configuring an object. It returns the receiver, allowing for
immediate chaining.
fun buildPushPayload(userId: Long, message: String): NotificationPayload {
return NotificationPayload().apply {
// Configure mutable properties
targetUserId = userId
body = message
priority = "HIGH"
}
}
Engineering Tip: Keep apply blocks focused purely on configuration. Avoid complex branching or I/O operations
inside apply to maintain predictability.
also: Observability and Side Effects
also is intended for actions that do not modify the object, such as logging or validation. It returns the original
receiver unchanged.
fun processPayment(orderId: Long, amount: Long): PaymentResult {
return executePayment(orderId, amount)
.also { result ->
println("Payment processed: id=${result.transactionId}, status=${result.status}")
}
.also { result ->
check(result.status != "FAILED") { "Validation failed" }
}
}
Decision Heuristics
To choose the correct function, evaluate the following:
- Do you need to return the object itself?
- Yes, for configuration → apply
- Yes, for side effects → also
- Do you need to return a computed result?
- Yes, and the object might be null → let (for
it) or run (forthis) - Yes, and the object is guaranteed non-null → with
- Yes, and the object might be null → let (for
Summary
Scope functions should enhance code clarity, not just reduce line count. Use apply for initialization, also for
logging, and let/run for transformations. Clear, consistent usage of these functions allows reviewers to immediately
understand the intent of a logic block.