One of Kotlin’s most interesting features is its collection of scope functions — apply
, let
, also
, and run
. These are higher-order functions, meaning they take other functions (lambdas) as parameters. They can make code more fluent and expressive, but they can also confuse even experienced developers.
Let’s break them down, clarify their differences, and understand when to use each one.
🧠 What Is a Higher-Order Function? Link to heading
In Kotlin, a higher-order function is a function that takes another function as an argument or returns a function. All four of these scope functions fall into that category.
fun <T> T.apply(block: T.() -> Unit): T
fun <T, R> T.let(block: (T) -> R): R
fun <T> T.also(block: (T) -> Unit): T
fun <T, R> T.run(block: T.() -> R): R
Each one:
- Operates on a context object (the object you’re calling it on)
- Provides a scoped lambda to operate on that object
- Returns either the object itself or the result of the lambda
⚙️ Summary Table Link to heading
Function | Context | Returns | Use When… |
---|---|---|---|
apply |
this |
receiver (same object) | Configuring or initializing an object |
let |
it |
result of lambda | Transforming or chaining expressions |
also |
it |
receiver (same object) | Logging, debugging, side-effects |
run |
this |
result of lambda | Running expressions on an object, often with a result |
🔧 apply
– Configuration
Link to heading
Use apply
when you’re setting up or configuring an object. The object is available as this
and the return value is the original object.
This is especially useful because it logically groups the initialization of the object, keeping setup code compact, clear, and close to the object definition. That alone makes your code easier to scan and understand at a glance.
val user = User().apply {
name = "Red Mug"
email = "user@example.com"
active = true
}
✅ Use apply
when:
- You’re building or configuring an object and want to group that logic clearly
- You prefer a fluent, readable syntax without introducing a temporary variable
- You don’t need a return value from the block, just the object itself
🔄 let
– Transformation or Safe Call
Link to heading
Use let
when you want to transform the object or perform actions on non-null values in a chain. The object is passed as it
and the lambda result becomes the return value.
This is similar to run
, but unlike run
, let
uses it
as the context object (not this
), and is more commonly used with safe calls and functional-style chaining where the context shouldn’t change. let
is also commonly used to scope variables tightly, especially within nullable expressions.
It’s especially effective when chaining together object wrappers like:
val reader = FileInputStream("file.txt")
.let { BufferedInputStream(it) }
.let { InputStreamReader(it) }
.let { BufferedReader(it) }
val firstLine = reader.readLine()
In this case, each step transforms the input and passes it forward, keeping variable scoping narrow and intent obvious. Here’s another example of let with a nullable value:
val length = name?.let {
println("Name is $it")
it.length
}
✅ Use let
when:
- You need a different return value (e.g., mapping, length, calculations)
- You’re working in a safe-call (
?.
) chain - You want to limit variable scope
📋 also
– Side Effects
Link to heading
Use also
when you want to perform side effects (like logging or validation) but still return the original object. The object is passed as it
.
val result = fetchData().also {
println("Fetched: $it")
}
✅ Use also
when:
- You’re logging, validating, or debugging
- You want to preserve the original object unchanged
🧪 run
– Execution Block with Result
Link to heading
Use run
when you want to operate on an object and return a result. The object is available as this
, and the return value is whatever your lambda returns.
While run
and let
both return the result of their lambda, run
uses this
as the context object. This makes it more suitable when you want to work directly with an object’s methods or properties without needing to reference it
repeatedly. run
is typically chosen when the lambda is more focused on using the object than transforming it.
val userNameLength = user.run {
println("User: $name")
name.length
}
✅ Use run
when:
- You want to perform an action and return a computed result
- You’re working in a scoped block
- You want cleaner syntax for immediate evaluation
🧾 Addendum: What About use
?
Link to heading
Kotlin includes another scoped function called use
, which is commonly encountered when working with Closeable
resources. While it behaves similarly to run
or let
by executing a lambda and returning its result, it’s not generally grouped with the other scope functions because it’s tied specifically to the lifecycle of I/O or resource-bound operations.
use
ensures proper cleanup by automatically closing the resource after the block completes — similar to Java’s try-with-resources.
File("file.txt").bufferedReader().use {
println(it.readLine())
}
✅ Use use
when:
- You’re working with files, streams, or other
Closeable
types - You want concise and safe cleanup behavior without a manual
try/finally
Looking to apply this in real-world backend services? Browse more Kotlin + Spring patterns in the other posts on this blog.