Kotlin scope functions execute a block of code in the context of an object. The standard library provides five commonly used scope functions: let, run, with, apply, and also. They differ mainly in how the context object is referenced inside the lambda and what the function returns.

This tutorial focuses first on let, also, and apply, and then explains run. It also compares all five Kotlin scope functions so that you can choose one based on the purpose of the block rather than chaining them unnecessarily.

Kotlin Scope Functions at a Glance

The following table summarizes the two decisions that usually determine which Kotlin scope function to use: whether the context object is referenced as this or it, and whether the function returns the context object or the lambda result.

Scope functionContext referenceReturn valueCommon purpose
letitLambda resultNull-safe calls and value transformations
runthisLambda resultObject configuration followed by a computed result
withthisLambda resultCalling several members on an existing non-null object
applythisContext objectObject initialization and configuration
alsoitContext objectLogging, validation, and other side operations

Use this table as a selection guide, not as a rule that every operation must be written with a scope function. A regular variable, function call, or if statement is often clearer when the scope block does not improve readability.

Working with let, also, and apply

Assume you can fetch data using the following function :

fun getPlayers(): List<Player>?

Here, the Player class is defined as follows:

data class Player(val name: String, val bestScore: Int)

How would you perform the following sequence of operations to the getPlayers() function result?

  1. Print the original set of players in the list to the console
  2. Sort the collection of the Player objects in descending order
  3. Transform the collection of Player objects into a list of strings obtained from the Player.name property
  4. Limit the collection to the first element and print it to the console

In order to accomplish the task, you first need to get familiar with the characteristics of the letalso, and apply functions. They are provided in the standard library as extension functions for a generic type. Here are the headers of the letalso, and apply functions:

public inline fun <T, R> T.let(block: (T) -> R): R

public inline fun <T> T.also(block: (T) -> Unit): T

public inline fun <T> T.apply(block: T.() -> Unit): T

They look similar; however, there are some subtle differences in the return types and in parameters. The following table compares the three functions:

FunctionReturn typeArgument in block argumentBlock argument definition
LetR (from block body)Explicit it(T) -> R
AlsoT (this)Explicit it(T) -> Unit
ApplyT (this)Implicit thisT.() -> Unit

Build the Player Processing Chain with let, also, and apply

  1. Use the let function together with the safe operator to assure null safety:
    getPlayers()?.let {}
  2. Inside the let function’s lambda parameter block, use the also() function to print the original set of players in the list to the console:
    getPlayers()?.let {
        it.also {
    println("${it.size} players records fetched")
    println(it)
    }
    }
  3. Use the let() function to perform sorting and mapping transformations:
    getPlayers()?.let {
        it.also {
    println("${it.size} players records fetched")
    println(it)
    }.let {
            it.sortedByDescending { it.bestScore }
        }
  4. Limit the collection of players to a single Player instance with the highest score using the let() function:
    getPlayers()?.let {
        it.also {
    println("${it.size} players records fetched")
    println(it)
    }.let {
            it.sortedByDescending { it.bestScore }
        }.let {
            it.first()
    }
  5. Print the name of the best player to the console:
    getPlayers()?.let {
        it.also {
    println("${it.size} players records fetched")
    println(it)
    }.let {
            it.sortedByDescending { it.bestScore }
        }.let {
            it.first()
    }.apply {
    val name = this.name
    print("Best Player: $name")
    }
    }

How the Player Scope Function Chain Works

For the sake of testing the implementation, you can assume that the getPlayers() function returns the following results:

fun getPlayers(): List<Player>? = listOf(
        Player("Stefan Madej", 109),
        Player("Adam Ondra", 323),
        Player("Chris Charma", 239))

The code you have implemented will print the following output to the console:

3 players records fetched
[Player(name=Stefan Madej, bestScore=109), Player(name=Adam Ondra, bestScore=323), Player(name=Chris Charma, bestScore=239)]
Best Player: Adam Ondra

also returns the same list on which it was called, so printing does not interrupt the chain. Each let call returns the result of its lambda, allowing the value to change from a list of players to a sorted list and then to a single Player. The final apply block returns that same Player after printing its name.

Note that, in the case of the apply() function, you can omit the this keyword while accessing class properties and functions inside the function lambda block:

apply {
print("Best Player: $name")
}

It was used in the above example code just for the sake of clarity.

Simplify the Player Example Without Excessive Scope Function Chaining

Although the previous example demonstrates several functions, repeated nested let calls can make a straightforward transformation harder to read. The same task can be expressed with collection operations and one also block:

</>
Copy
val bestPlayer = getPlayers()
    ?.also { players ->
        println("${players.size} player records fetched")
        println(players)
    }
    ?.maxByOrNull { player -> player.bestScore }

bestPlayer?.let { player ->
    println("Best Player: ${player.name}")
}

maxByOrNull() directly expresses the intention of finding the player with the highest score. It also handles an empty list by returning null, whereas first() throws an exception when the sorted list is empty.

Use let for Kotlin Null-Safe Transformations

The most useful feature of the let() function is that it can be used to assure the null safety of the given object. In the following example, inside the let scope, the players argument will always hold a not null value even if some background thread tries to modify the original value of the mutable results variable:

var result: List<Player>? = getPlayers()
result?.let { players: List<Player> ->
    ...
}

The safe-call operator invokes let only when result is not null. The lambda parameter receives the non-null value captured for that call. Naming the parameter players is often clearer than using it, especially when the block contains nested lambdas.

</>
Copy
val topPlayerName = getPlayers()?.let { players ->
    players.maxByOrNull { player -> player.bestScore }?.name
}

println(topPlayerName ?: "No players found")

Because let returns the final expression from its block, it is suitable for converting one value into another. In this example, a nullable player list is transformed into a nullable player name.

Initializing Objects with the Kotlin run Scope Function

The run() extension function is useful when a block needs access to an object’s members through this and must return a value computed by the block. It combines the receiver style of apply with the result-returning behavior of let.

The following is its function header:

public inline fun <T, R> T.run(block: T.() -> R): R

It is declared as an extension function for a generic type. The run function provides an implicit this parameter inside the block argument and returns the result of the block execution.

Configure Calendar.Builder and Return a Calendar with run

  1. Declare an instance of the Calendar.Builder class and apply the run() function to it:
    val calendar = Calendar.Builder().run {
    build()
    }
  2. Add the desired properties to the builder:
    val calendar = Calendar.Builder().run {
    setCalendarType("iso8601")
        setDate(2018, 1, 18)
        setTimeZone(TimeZone.getTimeZone("GMT-8:00"))
        build()
    }
  3. Print the date from the calendar to the console:
    val calendar = Calendar.Builder().run {
    setCalendarType("iso8601")
        setDate(2018, 1, 18)
        setTimeZone(TimeZone.getTimeZone("GMT-8:00"))
        build()
    }
    print(calendar.time)

How run Returns the Calendar Built Inside the Scope

The run function is applied to the Calendar.Builder instance. Inside the lambda passed to the run function, you can access the Calendar.Builder properties and methods via the this modifier. In other words, inside the run function block, you can access the scope of the Calendar.Builder instance. In the above code, you’ve omitted to invoke Builder methods with the this keyword. You can call them directly because the run function allows accessing the Builder instance inside its scope via an implicit this modifier.

The final expression is build(), so the result of the entire run call is a Calendar, not the original Calendar.Builder. This is the main distinction between run and apply.

Use Nullable run Blocks for Object Member Access

You can also use the run() function together with the safe ? operator to provide null safety of the object referenced by the this keyword inside the run() function scope. You can see it in action in the following code snippet to configure the Android WebView class:

webview.settings?.run {
    this.javaScriptEnabled = true
    this.domStorageEnabled = false
}

In the preceding code snippet, the block runs only when settings is not null. Within the block, the settings object is the implicit receiver, so the explicit this qualifiers may be omitted.

Kotlin run vs let

Both run and let return the lambda result. The main difference is how the context object is referenced.

  • Use let when the context object should be an explicit lambda argument such as it or a descriptive name.
  • Use run when the block mainly calls properties and methods on the context object through this.
</>
Copy
val nameLengthWithLet = player?.let { selectedPlayer ->
    selectedPlayer.name.length
}

val nameLengthWithRun = player?.run {
    name.length
}

Both expressions produce the same type. The clearer choice depends on whether naming the object improves the block or whether receiver-style member access is easier to read.

Kotlin apply vs also

apply and also both return the original context object, making them suitable for continuing a call chain. Their difference is the lambda receiver.

  • Use apply when configuring the object’s properties or calling several of its methods.
  • Use also for side operations that use the object as an argument, such as logging or validation.
</>
Copy
val player = PlayerSettings().apply {
    displayName = "Adam"
    notificationsEnabled = true
}.also { settings ->
    println("Created settings for ${settings.displayName}")
}

The apply block configures the new object through this. The following also block observes the configured object without changing the value returned by the chain.

Using the Kotlin with Function

with is the only one of the five scope functions that is normally called as a regular function rather than as an extension. It accepts the context object as an argument, exposes it as this, and returns the lambda result.

</>
Copy
with(contextObject) {
    // Access contextObject members through this
    // The final expression becomes the return value
}
</>
Copy
val playerSummary = with(Player("Maya", 280)) {
    "$name scored $bestScore points"
}

println(playerSummary)

with does not provide null-safe invocation through ?.with. For a nullable receiver, use ?.run or handle null before calling with.

Common Kotlin Scope Function Mistakes

  • Chaining too many scope functions: multiple nested let, run, or apply blocks can hide the value being transformed. Introduce a named variable when it improves clarity.
  • Confusing the return value: let, run, and with return the lambda result, while apply and also return the context object.
  • Using implicit receivers in nested blocks: nested this receivers can make it unclear which object a property belongs to. Use labels or explicit names when needed.
  • Using it in deeply nested lambdas: rename lambda parameters to terms such as player, settings, or response.
  • Using apply only to calculate a value: choose run when the intended result is the block’s final expression rather than the original object.
  • Using let for every nullable value: a safe call, Elvis operator, or regular if statement may be simpler for a single operation.

Kotlin Scope Functions FAQs

What are the five scope functions in Kotlin?

The five commonly used Kotlin scope functions are let, run, with, apply, and also. They execute a lambda in the context of an object but differ in the context reference and returned value.

What is the difference between run and let in Kotlin?

Both return the lambda result. let passes the object as an explicit argument, usually named it, while run exposes the object as the implicit receiver this.

When should apply be used instead of run?

Use apply when configuring an object and continuing with the same object because it returns the receiver. Use run when the block should return a different computed value.

Can let be used for Kotlin null safety?

Yes. An expression such as nullableValue?.let { value -> ... } executes the block only when the value is not null. Inside the block, the lambda parameter is non-null.

Do Kotlin scope functions improve performance?

Scope functions are primarily readability and organization tools. They are declared inline in the standard library, but they should be selected for clear code rather than assumed performance gains.

Editorial QA Checklist for Kotlin Scope Functions

  • Verify that each example identifies whether the context object is referenced as this or it.
  • Confirm that examples correctly distinguish lambda-result functions from context-object-returning functions.
  • Check nullable examples to ensure the safe-call operator prevents execution when the receiver is null.
  • Avoid recommending nested scope functions when a named variable or collection operator is clearer.
  • Use descriptive lambda argument names in examples containing more than one nested lambda.
  • Confirm that new Kotlin examples use the language-kotlin PrismJS class and syntax-only blocks also use syntax.