let, run, apply, also, with: Picking the Right Scope Function


Kotlin’s standard library has five small functions that all do roughly the same thing — run a block of code on an object — and new Kotlin developers reliably mix them up. let, run, with, apply, also. They look interchangeable, and choosing wrong still compiles, which is why the confusion lingers. But they’re separated by just two questions, and once you internalize those, the right one is obvious every time.

This post follows Java interop, and expands on the brief mention in the lambdas series.

The two questions

Every scope function differs along exactly two axes:

  1. How do you refer to the object inside the block? As this (an implicit receiver, so members need no prefix) or as it (an ordinary parameter)?
  2. What does the block return? The block’s own result, or the original object?

Two questions, two answers each — that’s the whole map. Here’s how the five fall out:

FunctionObject isReturns
letitblock result
runthisblock result
withthisblock result
applythisthe object
alsoitthe object

Returns the result: let, run, with

Use these when you want the block to compute something.

let takes the object as it and returns the block’s result. Its signature use is null handling — ?.let { } runs only on a non-null value:

val length = name?.let { it.trim().length } ?: 0

run is the same but with this, so it suits a block that calls several members of the object:

val area = rectangle.run { width * height }

with is run written as a regular function rather than an extension — same this, same returned result, just a different call shape. Reach for it to group several operations on one object:

with(canvas) {
    drawLine(0, 0, 10, 10)
    drawCircle(5, 5, 3)
}

Returns the object: apply, also

Use these when you want to do something to the object and then keep it.

apply exposes the object as this and returns it — the canonical way to configure something right after creating it:

val file = File("out.txt").apply {
    createNewFile()
    setReadable(true)
}                                  // file is the File, configured

also exposes it as it and returns it — ideal for a side effect that shouldn’t pretend to be part of the object, like logging or validation, slipped into a chain:

val user = loadUser()
    .also { println("loaded $it") }
    .also { require(it.isValid) }

How to choose

Work backwards through the two questions. Do I need the object back, or a computed value? That picks the column. Do I want to call members without a prefix, or is it clearer? That picks this versus it. In practice two dominate: apply for configuring a fresh object, and let for “do this if not null.” The other three are worth recognizing but you’ll write them less.

takeIf and takeUnless

Two close cousins round out the family. takeIf returns the object when a condition holds and null otherwise — turning a predicate into a nullable you can chain with ?::

val name = input.takeIf { it.isNotBlank() } ?: return

takeUnless is the negation. Both pair naturally with ?.let for “use this only if it qualifies.”

A worked example

The scope functions earn their place when they combine. Building and validating an object can read as one uninterrupted flow:

fun buildRequest(url: String, body: String): Request =
    Request(url)
        .apply { method = "POST" }              // configure, return the Request
        .also { log.debug("prepared {}", it) }  // side effect, return it
        .takeIf { body.isNotEmpty() }            // keep only if there's a body
        ?.apply { payload = body }
        ?: error("empty body")

Read it top to bottom: apply configures, also logs without breaking the chain, takeIf gates, and the Elvis handles the reject case — no intermediate variables, and each step’s purpose is legible from the function it uses.

The common mistake

The trap is reaching for a scope function where a plain variable or a simple ?. would be clearer. Wrapping a single non-null call in let, or a one-liner in run, adds a layer of this/it indirection that earns nothing — and nesting them is worse, turning this and it into a guessing game about which object you’re looking at. They pay off when they remove a temporary variable or tidy a chain, not as a reflex. If a reader has to stop and work out what this points at, the scope function cost more than it saved.

Final thoughts

The scope functions aren’t five tools — they’re one tool with two switches: receiver-versus-parameter and result-versus-object. Memorizing the table is less useful than internalizing those two questions, because the questions tell you why each one fits — and which of the five you actually wanted.

Next, a feature Java deliberately refused for decades: operator overloading — and how Kotlin allows it without the chaos everyone feared.