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 transformations
  • run — compute a result
  • with — group calls on the same object
  • apply — configure an object
  • also — side effects and logging
  • When to use which
  • takeIf and takeUnless
  • Chaining and common patterns

The Five Scope Functions

Here is a quick comparison:

FunctionObject refReturn valueBest for
letitLambda resultNull checks, transforms
runthisLambda resultCompute result
withthisLambda resultGroup calls
applythisObjectConfigure object
alsoitObjectSide 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 resultReturns object
Object as itletalso
Object as thisrun / withapply

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 takeIf to filter a single value before processing
  • Use takeUnless when the negative condition reads more naturally
  • Combine with ?.let for 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

FunctionObject refReturnsBest for
letitLambda resultNull checks, transforms
runthisLambda resultCompute result
withthisLambda resultGroup calls
applythisObjectConfigure object
alsoitObjectSide effects, logging

Related functions:

  • takeIf {} — returns object if predicate is true, null otherwise
  • takeUnless {} — 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:

KT-11 Source Code 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.