In the previous tutorial, you learned about extension functions. Now let’s learn about scope functions. Kotlin has five scope functions: let, run, with, apply, and also. They execute a block of code on an object and differ in how they refer to the object and what they return.
These functions are used everywhere in Kotlin code. Understanding them will make your code cleaner and more readable.
In this tutorial, you will learn:
let— null checks and transformationsrun— compute a resultwith— group calls on the same objectapply— configure an objectalso— side effects and logging- When to use which
takeIfandtakeUnless- Chaining and common patterns
The Five Scope Functions
Here is a quick comparison:
| Function | Object ref | Return value | Best for |
|---|---|---|---|
let | it | Lambda result | Null checks, transforms |
run | this | Lambda result | Compute result |
with | this | Lambda result | Group calls |
apply | this | Object | Configure object |
also | it | Object | Side effects, logging |
Let’s look at each one in detail.
let
let executes a block with the object as it. It returns the result of the lambda.
Null Check with let
The most common use of let is null-safe calls:
val name: String? = "Alex"
name?.let {
println("Name is not null: $it")
println("Length: ${it.length}")
}
val nullName: String? = null
nullName?.let {
println("This won't print")
} ?: println("Name is null")
Transform with let
Use let to scope a transformation:
val numbers = listOf(1, 2, 3, 4, 5)
val result = numbers
.filter { it > 2 }
.let { filtered ->
println("Filtered: $filtered")
filtered.sum()
}
println("Sum: $result") // 12
Scoping with let
Limit a variable’s scope:
val message = "Hello World".let {
val upper = it.uppercase()
val length = it.length
"$upper ($length chars)"
}
println(message) // HELLO WORLD (11 chars)
run
run executes a block with the object as this. It returns the result of the lambda.
run on an Object
data class User(
var name: String = "",
var email: String = "",
var age: Int = 0
)
val user = User("Alex", "alex@mail.com", 25)
val greeting = user.run {
"Hello, $name! Email: $email"
}
println(greeting) // Hello, Alex! Email: alex@mail.com
Inside run, you access properties directly because this is the user.
run Without a Receiver
You can use run as a standalone code block:
val hexColor = run {
val r = 255
val g = 128
val b = 0
"#%02X%02X%02X".format(r, g, b)
}
println(hexColor) // #FF8000
run with Null Safety
val user: User? = null
val result = user?.run {
"User: $name"
} ?: "No user"
println(result) // No user
with
with executes a block with the object as this. It returns the result of the lambda. Unlike the others, with is not an extension function — it takes the object as a parameter.
val user = User("Sam", "sam@mail.com", 30)
val description = with(user) {
println("Name: $name")
println("Email: $email")
"User $name, age $age"
}
println(description) // User Sam, age 30
with is great for grouping operations on the same object:
val text = with(StringBuilder()) {
append("Hello")
append(", ")
append("World!")
toString()
}
println(text) // Hello, World!
apply
apply executes a block with the object as this. It returns the object itself. This makes it perfect for configuring objects.
data class Config(
var host: String = "localhost",
var port: Int = 8080,
var debug: Boolean = false,
var maxRetries: Int = 3
)
val config = Config().apply {
host = "api.example.com"
port = 443
debug = true
maxRetries = 5
}
println(config)
// Config(host=api.example.com, port=443, debug=true, maxRetries=5)
The key difference between apply and with is that apply returns the object. This lets you assign the result to a variable directly.
Since apply returns the object, you can use it for builder-style initialization:
val user = User().apply {
name = "Jordan"
email = "jordan@mail.com"
age = 28
}
println(user) // User(name=Jordan, email=jordan@mail.com, age=28)
also
also executes a block with the object as it. It returns the object itself. Use it for side effects like logging and validation.
Logging in Chains
val numbers = listOf(3, 1, 4, 1, 5, 9, 2, 6)
val sorted = numbers
.also { println("Original: $it") }
.sorted()
.also { println("Sorted: $it") }
.filter { it > 3 }
.also { println("Filtered: $it") }
Validation After Creation
val user = User().apply {
name = "Alex"
email = "alex@mail.com"
age = 25
}.also {
require(it.name.isNotBlank()) { "Name is required" }
require(it.email.contains("@")) { "Valid email required" }
println("User validated: ${it.name}")
}
When to Use Which
Here are simple rules to help you decide:
Need to configure an object? Use apply:
val user = User().apply {
name = "Alex"
email = "alex@mail.com"
}
Need to log or debug? Use also:
user.also { println("Created user: ${it.name}") }
Need a null check? Use let:
val email: String? = user.email
email?.let { println("Sending email to: $it") }
Need to compute a result? Use run:
val isValid = user.run {
name.isNotBlank() && email.contains("@") && age > 0
}
Need to group calls? Use with:
with(user) {
println("Name: $name, Email: $email, Age: $age")
}
Chaining Scope Functions
You can chain scope functions together:
val user = User()
.apply {
name = "Sam"
email = "sam@mail.com"
age = 30
}
.also { println("Created: ${it.name}") }
.also {
require(it.name.isNotBlank())
require(it.email.contains("@"))
}
You can also chain let for transformations:
val result = " Hello, World! "
.let { it.trim() }
.let { it.uppercase() }
println(result) // HELLO, WORLD!
Be careful not to overuse chaining. If the code becomes hard to read, break it into separate statements.
Scope Functions with Collections
Scope functions work great with collection operations:
val users = listOf(
User("Alex", "alex@mail.com", 25),
User("Sam", "sam@mail.com", 30),
User("Jordan", "jordan@mail.com", 22)
)
// Use let to transform the result
val report = users
.filter { it.age >= 25 }
.let { filtered ->
"Found ${filtered.size} users: ${filtered.map { it.name }}"
}
println(report) // Found 2 users: [Alex, Sam]
// Use also for debugging
val sorted = users
.also { println("Before sort: ${it.map { u -> u.name }}") }
.sortedBy { it.age }
.also { println("After sort: ${it.map { u -> u.name }}") }
this vs it
The key difference between scope functions is how they refer to the object.
this (used by run, with, apply): The object becomes the receiver. You access its properties directly, like you are inside the class.
val user = User("Alex", "alex@mail.com", 25)
user.apply {
// `this` is the user — access properties directly
println(name) // No need for user.name or it.name
println(email)
}
it (used by let, also): The object is passed as a parameter. You access it through it (or a custom name).
val user = User("Alex", "alex@mail.com", 25)
user.let {
// `it` is the user — must use it.property
println(it.name)
println(it.email)
}
// Or with a custom name
user.let { currentUser ->
println(currentUser.name)
}
Use this when you need to call many methods on the object. Use it when you need to pass the object to other functions or when the lambda is short.
Return Value: Object vs Lambda Result
The other key difference is what the function returns.
Returns lambda result (let, run, with): The last expression in the lambda is the return value.
val length = "Hello".let { it.length } // Returns 5
val greeting = user.run { "Hello, $name!" } // Returns "Hello, Alex!"
Returns the object (apply, also): The original object is returned, so you can chain calls.
val user = User().apply { name = "Alex" } // Returns the User
val same = user.also { println(it.name) } // Returns the same User
This table might help:
| Returns lambda result | Returns object | |
|---|---|---|
Object as it | let | also |
Object as this | run / with | apply |
Common Patterns
Safe Call + let for Null Handling
fun processName(name: String?) {
name?.let { println("Processing: $it") }
?: println("No name provided")
}
processName("Alex") // Processing: Alex
processName(null) // No name provided
apply for Builder-Style Initialization
fun createUser(name: String, email: String): User {
return User().apply {
this.name = name
this.email = email
}
}
run for Computing from Nullable
val user: User? = User("Alex", "alex@mail.com", 25)
val displayName = user?.run {
if (age > 18) "$name (adult)" else "$name (minor)"
} ?: "Unknown"
also for Debugging in Chains
val scores = listOf(85, 92, 78, 95, 88)
val topScores = scores
.sorted()
.also { println("Sorted: $it") }
.takeLast(3)
.also { println("Top 3: $it") }
println("Average: ${topScores.average()}")
takeIf and takeUnless
Kotlin has two related functions that work well with scope functions: takeIf and takeUnless. They return the object if it matches a condition, or null if it does not.
takeIf
takeIf returns the object if the predicate is true. Otherwise, it returns null:
val number = 42
val even = number.takeIf { it % 2 == 0 }
println(even) // 42
val odd = number.takeIf { it % 2 != 0 }
println(odd) // null
This is useful for conditional processing:
val input = "hello@mail.com"
val validEmail = input.takeIf { it.contains("@") }
?.let { "Sending to $it" }
?: "Invalid email"
println(validEmail) // Sending to hello@mail.com
takeUnless
takeUnless is the opposite of takeIf. It returns the object if the predicate is false:
val name = "Alex"
val result = name.takeUnless { it.isBlank() }
?: "Name is empty"
println(result) // Alex
When to Use
- Use
takeIfto filter a single value before processing - Use
takeUnlesswhen the negative condition reads more naturally - Combine with
?.letfor a clean null-safe pipeline
fun findUser(id: Int): String? {
return getUser(id)
.takeIf { it.isActive }
?.let { it.name }
}
Common Mistakes
Overusing Scope Functions
Don’t use scope functions when a simple statement is clearer:
// Bad — scope function adds no value
name?.let { println(it) }
// Good — simpler
if (name != null) println(name)
Nesting Too Many Scope Functions
// Bad — hard to read
user?.let { u ->
u.address?.let { addr ->
addr.city?.let { city ->
println(city)
}
}
}
// Good — use safe calls
println(user?.address?.city)
Using apply When You Need a Result
// Wrong — apply returns the object, not the result
val length = "Hello".apply { length } // Returns "Hello", not 5
// Correct — use let or run
val length = "Hello".let { it.length } // Returns 5
Summary
| Function | Object ref | Returns | Best for |
|---|---|---|---|
let | it | Lambda result | Null checks, transforms |
run | this | Lambda result | Compute result |
with | this | Lambda result | Group calls |
apply | this | Object | Configure object |
also | it | Object | Side effects, logging |
Related functions:
takeIf {}— returns object if predicate is true, null otherwisetakeUnless {}— returns object if predicate is false, null otherwise
Quick decision:
- Configure an object? apply
- Log or debug? also
- Null check? let
- Compute a result? run
- Group calls? with
Source Code
You can find the complete source code for this tutorial on GitHub:
What’s Next?
In this tutorial, you learned about all five scope functions: let, run, with, apply, and also. You now know when to use each one and how to chain them.
In the next tutorial, you will learn about sealed classes, enum classes, and value classes. These are powerful tools for modeling your data.
This is part 11 of the Kotlin Tutorial series. Check out Part 10: Extensions if you missed it. Need a quick reference? See the Kotlin Cheat Sheet.