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

The five scope functions decided by two questions — how the object is referenced (this or it) and what the block returns — plus takeIf/takeUnless and the nesting gotcha where this and it stop being obvious.

Kotlin’s standard library has five small functions that all do roughly the same thing — run a block 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. Everything here was run against Kotlin 2.4.10.

This chapter 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. The five fall out of the grid:

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

Returns the result: let, run, with

Use these when the block should 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 with this, so it suits a block calling 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, different call shape. Reach for it to group operations on one object:

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

The one practical difference between run and with: run is an extension, so it null-chains (canvas?.run { }), while with takes its subject as an argument and can’t.

Returns the object: apply, also

Use these to do something to the object and 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 configured File

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 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 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.”

The nesting gotcha

The trap is reaching for a scope function where a plain variable or a simple ?. would be clearer — but the sharp version is nesting them, where this and it stop being obvious. Inside a nested apply/run, an unqualified name resolves against the innermost receiver first, and it’s easy to write an assignment you think targets one object but actually targets another in scope:

var name = "outer"
User().apply {
    name = "inner"     // did this set User.name, or the outer `name`?
}

The answer depends on whether User has a name member and on the exact scopes involved, and that ambiguity is the problem — a reader (and sometimes the author) can’t tell at a glance. When you must nest, disambiguate with a label ([email protected]) or, better, don’t nest: give the inner object a real name and drop the scope function. If a reader has to stop and work out what this points at, the scope function cost more than it saved.

A worked example

The scope functions earn their place when they combine, building and validating an object as one 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 top to bottom: apply configures, also logs without breaking the chain, takeIf gates, and the Elvis handles the reject case — no intermediate variables, each step’s purpose legible from the function it uses.

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. The discipline is to reach for them only when they remove a temporary or tidy a chain, and to resist nesting them, where the this/it they trade on turns from a convenience into a puzzle.

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

Practice: reinforce this with the companion workbook — short, click-to-reveal problems.

Comments