Scope functions in Kotlin are five standard library functions (let, run, with, apply, also) that execute a block of code in the context of an object. They differ in the way they access the object (via it or this) and the return value (context object or lambda result). According to Kotlin documentation (2026), the correct choice of scope function reduces boilerplate code by 25–40%. Scope functions help write object initialization, configuration and transformations in a declarative style.
Key takeaways
Scope functions are a set of five functions (let, run, with, apply, also) built into the Kotlin standard library. Each of them takes a lambda and temporarily changes the scope so that inside the lambda the code executes in the context of a specific object. This eliminates repeating the object name for multiple calls and groups related operations into a single block.
Two key characteristics distinguish scope functions: the way to access the context object (via this or via it) and the return value (the context object itself or the lambda result). Access via this makes the object an implicit receiver — methods and properties are called without a prefix. Access via it passes the object as an explicit lambda parameter. Returning the object allows chaining, returning the result allows assigning the transformed value.
Historically, scope functions emerged as a replacement for the classic Builder pattern and temporary variables for initialization. Instead of creating a separate builder class, the developer writes apply { property1 = ...; property2 = ... }. This reduces the amount of code and makes initialization more readable. In the Kotlin ecosystem, scope functions are used across all frameworks and libraries as a standard language idiom.
Choosing the right scope function is determined by two questions: whether you want to use this or it to access the object, and whether you want to return the object itself or the lambda result. The combination of two binary features gives four possible options, and with occupies a special place as the only function that is not an extension.
| Function | Access | Return | Extension? | Typical scenario |
|---|---|---|---|---|
| let | it | lambda result | yes | Transformation, null-check |
| run | this | lambda result | yes | Computation with context |
| with | this | lambda result | no | Grouping calls |
| apply | this | context object | yes | Property initialization |
| also | it | context object | yes | Side effects, logging |
To configure an object without returning a new value, use apply (access via this) or also (access via it). To compute a new value based on an object, use let (access via it) or run (access via this). To group calls without chaining, use with. Following this scheme makes the code predictable for other developers familiar with Kotlin.
apply is a scope function that returns the context object and provides access to it via this. This is the ideal tool for initializing object properties after creation. Inside the apply block you can set properties without repeating the object name. The method returns the object itself, allowing apply to be embedded in chains or used in initializers.
data class ServerConfig(
var host: String = "localhost",
var port: Int = 8080,
var useTls: Boolean = false
)
val config = ServerConfig().apply {
host = "api.example.com"
port = 443
useTls = true
}
In the listing apply configures ServerConfig by accessing properties directly via this (this is omitted). Without apply you would have to write config.host = ..., config.port = ... — three repetitive references to the same variable. Returning the object allows storing the configuration result in val config. also, unlike apply, passes the object via it and is suitable for side effects.
fun saveUser(user: User) {
validate(user).also { result ->
println("Validation result: $result")
}
val savedUser = user.also {
log("Saving user ${it.id}")
database.save(it)
}
}
In the example also is used for logging — while returning the original object, it performs a side effect (outputting a message) without modifying the object. This is a chain: validate(user) returns User, also logs the result and passes the user forward. also is also useful for adding elements to collections or debugging in the middle of a transformation chain.
let is a scope function that returns the lambda result and passes the context via it. This is the primary function for null-safe transformations: if the object is nullable, the ?.let { } combination will execute the block only for non-null values. let is also used to limit the scope of temporary variables and for map-like transformations from one type to another.
fun findUser(id: Int): User?
val displayName = findUser(42)?.let { user ->
"${user.name} (${user.email})"
} ?: "Unknown user"
// let with collection transformation
val numbers = listOf("1", "2", "3")
val parsed = numbers.firstOrNull()?.let {
it.toIntOrNull()
} ?: 0
In the first example let is called via ?. — the block executes only if findUser returned non-null. The lambda takes user via it (can be renamed for clarity) and returns a formatted string. If the user is not found, the Elvis operator ?: provides a default value. In the second example let converts a string to a number with a potential null.
run is an analogue of let with access via this, returning the lambda result. run is convenient for computations that require object context but return a new value. Calling run() without an object (as run { ... }) creates a temporary scope for variables. with is a non-extension with access via this, suitable for grouping calls without chaining.
// run as context-driven computation
val transformed = listOf(1, 2, 3).run {
filter { it > 1 }.map { it * 10 }
}
println(transformed) // [20, 30]
// with for grouping calls
val info = with(StringBuilder()) {
append("Name: ")
append("Alice")
toString()
}
In the example run on a list returns the result of filtering and mapping — the transformation itself, not the list. with takes StringBuilder as an argument (not via dot) and works inside with this: append, append, toString. The result of with is the info string. The choice between run and with is often subjective, but with is preferable when the object is passed from another place without chaining.
with differs from other scope functions in that it is not an extension function. It takes the object as the first argument and the lambda as the second. Inside the lambda the object is accessible via this. with is convenient for grouping object method calls without saving the result or when you need to temporarily “enter” a context to read several properties.
val user = User("Alice", "alice@example.com", "Admin")
val summary = with(user) {
"User $name has role $role and email $email"
}
println(summary)
// with for UI component initialization
with(TextView(context)) {
text = "Hello"
textSize = 18.0f
setTextColor(Color.BLUE)
}
In the first example with forms the summary string by accessing user properties directly via this. Without with you would have to write "User ${user.name} has role ${user.role}" — repeated user prefixes. In the second example with groups TextView configuration: all methods and properties are called on one object without repetition. Drawback of with — inability to use it in a chain, since it is not an extension and does not return the context object.
Frequently asked questions
Use apply — it returns the object and provides access via this, allowing you to set properties without repeating the object name. also is suitable if you need an explicit it parameter to distinguish from object properties.
let passes the context via it (explicit parameter), run — via this (implicit receiver). let is convenient for null-checks and transformations, run — for computations with context. Both return the lambda result.
with is not an extension — it takes the object as an argument. Use with to group calls when chaining is not needed. apply is an extension that returns the object, convenient in initializers and builder patterns.
Yes, but be careful with this — nested this creates confusion. Use let or also with explicit names for nested contexts to avoid ambiguity when referencing the outer object.
All scope functions are inline, so they do not create overhead from anonymous classes. The performance difference between them is negligible. The choice should be based on readability and conventions, not speed.
Summary
We will develop a mobile application turnkey
IT Sectr creates iOS and Android applications for startups and businesses since 2017. We will advise you and propose the best solution.
Read also