One of Kotlin’s most interesting features is its collection of scope functionsapply, 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.