JavaRush /Courses /Kotlin SELF /Scope function contracts: let/run/apply/also/with

Scope function contracts: let/run/apply/also/with

Kotlin SELF
Level 28, Lesson 0
Available

1. Introduction

In short, scope functions are a “small agreement” between you and the standard library: “here’s an object, let me run a block of code next to it, and you return to me either the result of that block or the object itself.” Kotlin explicitly highlights a set of such functions in the standard library: let, run, with, apply, also.

The most common reason beginners dislike them at first: it seems like some special language construct. In reality, they’re just functions (yes, with slightly tricky signatures and lambdas). That is, the compiler doesn’t switch on a “magic mode”; it simply helps you write more compactly what you could have written with regular code anyway.

From a practical standpoint, they’re useful in three situations: when you want to “pipe” a value through a chain of computations, when you want to configure an object with several actions in a row, and when you need to insert a side step (for example, debug printing) without breaking the main logic.

2. Two axes of choice: this or it and what gets returned

So that scope functions don’t turn into a “pick one at random” lottery, it’s convenient to remember them not by name but along two axes. It’s like choosing shoes: first you decide what they’re for (running/office/muddy puddles), then the size. The logic here is similar: first decide how you refer to the object inside the block, and then what should be returned to the outside.

The first axis: how the object is available inside the block. Either as it (an explicit lambda parameter), or as this (an implicit receiver). it usually makes code more “honest”: you can see you’re working with a parameter. this makes code more compact, but can sometimes hide the context (especially inside nested blocks).

The second axis: what the scope function call returns. Either the result of the last expression in the block is returned, or the original object itself is returned. And this is the key to choosing correctly. Because most scope-function mistakes look like this: “I wanted to get a number/string/list, but I got the original object back” (or vice versa).

3. Contract cheat sheet: this/it and the result

Before going through each function separately, it’s useful to see the table once. Then we’ll “bring it to life” with examples.

Function How the object is seen inside the block What is returned from the call
let
it
block result
also
it
original object
run (as obj.run { })
this
block result
apply
this
original object
with(obj) { }
this
block result

Note: with(obj) { ... } differs in that you pass the object as an argument (not via dot-call), but inside the block it still becomes this, and in terms of meaning with is very close to run.

4. Functions by intent: transform, configure, insert a side step

let: object as it, outward—evaluation result

let is often the first one that “clicks,” because it’s very similar to the familiar idea “take a value → transform it → get a new value.” If you previously wrote something like “read a string, trim spaces, compute the length,” then let is a way to package that as a small transformation without an extra intermediate val, while still avoiding turning everything into one unreadable lump.

Inside let the object is available as it. This is good when the block is short: literally 1–3 expressions. And what gets returned outward is the result of the last expression in the block—so let is great for computations.

Example: “take a string → get the length after trim().”

fun main() {
    val raw = "   Kotlin  "
    val len = raw.let { it.trim().length }

    println(len) // 6
}

Notice the feel: raw doesn’t change, and len is a new result. This is a very “functional” style: less mutation, more explicit transformations.

Another example: “take a string → produce a normalized version.” Still without complex tokenization; we’re just showing the idea of “transformation.”

fun main() {
    val input = "  HeLLo, KoTLiN  "
    val normalized = input.let { it.trim().lowercase() }

    println(normalized) // hello, kotlin
}

Here let helps keep the “took → did” in one place.

run: object as this, outward—evaluation result

After let, the next logical step is run in the form obj.run { ... }. In terms of meaning, it’s very similar to let: it also returns an evaluation result, but inside the block the object is available as this. This is convenient when you want to call multiple methods/properties on the object in a row and you don’t want to write it. every time.

Important: today we’re talking specifically about obj.run { ... }, i.e., calling it via dot on an object. (There’s also run { ... } without an object—that’s a separate story, and we’ll approach it carefully in the next lecture.)

Example: take a string and get “the last character after trimming spaces.”

fun main() {
    val raw = " Kotlin "
    val lastChar = raw.trim().run { last() }

    println(lastChar) // n
}

Why run is nice here: last() looks like a “native” string call. Yes, it’s a small thing, but readability of chains is built from small things.

One more example, now with StringBuilder. We need to build a string and return exactly a String, not the StringBuilder itself. This is a classic “I want the block result” case.

fun main() {
    val text = StringBuilder().run {
        append("Hello")
        append(", ")
        append("Kotlin")
        toString()
    }

    println(text) // Hello, Kotlin
}

The key idea: run returns whatever is last. So if the last thing is toString(), a string comes out.

with: almost like run, but without the dot

with often raises the question: “why do I need it if run exists?” The pragmatic answer: with reads nicely when you already have the object as a separate name and you want to perform a set of actions on it “inside the context,” but not format it as a dot-call chain.

Formally, with is not an extension call (obj.with), but a regular function (with(obj)). But inside the block the object is available as this, and the block result is returned outward, so by contract it’s very similar to run.

Example: we have a StringBuilder sb, and we want to build text in one place and get a string.

fun main() {
    val sb = StringBuilder()

    val result = with(sb) {
        append("Text ")
        append("Analyzer ")
        append("v0")
        toString()
    }

    println(result) // Text Analyzer v0
}

If you compare it with sb.run { ... }, the difference is mostly stylistic. A common choice is: if you already have obj. on the left—use run; if you want to “frame” the actions around the object—use with.

apply: object as this, outward—the same object

apply is about configuring an object. Imagine you bought IKEA furniture (or, if you’re lucky, just a chair), and you want to “assemble” it: screw on the legs, stick on a “do not sit, cat” label, put it in place. apply is like “assembled → returned the same chair back.”

The apply contract: inside the block the object is this, and outward you get the original object, already “configured.” That’s why apply is ideal when you want to create an object and prepare it for use in one expression.

A classic example is StringBuilder, but now we care about the object itself, because we want to keep working with it (or call toString() separately).

fun main() {
    val sb = StringBuilder().apply {
        append("Hello")
        append(", ")
        append("apply")
    }

    println(sb.toString()) // Hello, apply
}

A subtle point many people trip over: if you do val s = StringBuilder().apply { ... }, then s will have type StringBuilder, not String. This is not a Kotlin mistake; it’s you choosing the “return the object” contract.

Another example: you often need to prepare some kind of “report” as a multi-line string. We’re not building a full pipeline yet, but we can already show how convenient it is to configure a StringBuilder via apply.

fun main() {
    val report = StringBuilder().apply {
        appendLine("Text Analyzer")
        appendLine("--------------")
        appendLine("Status: OK")
    }.toString()

    println(report)
    // Text Analyzer
    // --------------
    // Status: OK
}

Note: here we do want a string, so after apply we call toString(). This is good style: apply is for configuration; a separate step is for getting the final representation.

also: object as it, outward—the same object

If apply is “configure the object,” then also is “do something extra along the way.” Most often, it’s a side effect: print to the console, write a log, check an invariant, compute something for debugging. And at the same time not break the main value flowing onward.

The also contract: inside the block the object is available as it, and outward the original object is returned. Why it rather than this? Because in also you very often want to explicitly see what exactly you’re “peeking at” or printing.

Example: sort a list and want to see “before/after” without changing the main result.

fun main() {
    val xs = listOf(3, 1, 2)
        .also { println("before: $it") }   // before: [3, 1, 2]
        .sorted()
        .also { println("after: $it") }    // after: [1, 2, 3]

    println(xs) // [1, 2, 3]
}

An important point: also should not be the place where the “main logic” lives. If you have a computation inside also that affects the result, that’s a red flag. also is good precisely as a “side pocket”: take out a flashlight, shine it, put it back, and keep going.

5. A mini rule of thumb and the Text Analyzer v0 example

Now for a useful “honest cheat sheet,” but we’ll phrase it not as a list, but as a thought: first ask yourself whether you want to get a new value or return the same object. If you want a new value, you’re almost always in the let/run/with world. If you want to return the same object, you’re in the apply/also world.

Then you refine the style inside the block. If the block is short and you want explicitness—pick the it variant (let for transformation, also for a side step). If the block looks like “configuration” with many method/property accesses—this is often more convenient (run to compute a result, or apply to configure an object).

Let’s carefully start our learning “Text Analyzer v0.” For now, without complex parsing, without tokens and frequencies: today we’ll just make a small scaffold where scope functions will look natural.

fun main() {
    println("Enter text:")
    val input = readln()

    val normalized = input
        .let { it.trim() }
        .also { println("Trimmed: '$it'") } // for example: Trimmed: 'Hello'

    val header = StringBuilder().apply {
        appendLine("Text Analyzer v0")
        appendLine("Length: ${normalized.length}")
    }.toString()

    print(header)
    // Text Analyzer v0
    // Length: 5
}

What matters here is the contracts, not aesthetics. let returns the computation result, so we got the trim() string and continued with it. also doesn’t change the value, but it let us insert “debug printing” along the way. apply configured the StringBuilder, but still returned a StringBuilder, so we called toString() separately.

We didn’t try to “cram” the entire text analysis in here—that will come a bit later. Today’s task is simpler: learn to see what will be returned from each function, and not confuse “transformation” with “configuration.”

6. Common mistakes when choosing let/run/apply/also/with

Mistake #1: confusing apply and run, then being surprised by the result type.
This usually looks like: you wanted to get a string, wrote a StringBuilder configuration, and expect a String to come out. But by contract apply returns the object—i.e., StringBuilder. The fix is not “add as String” (don’t do that), but choosing correctly: either use run if you want to return a computed result, or keep apply but add an explicit toString() step.

Mistake #2: trying to do the “main work” inside also.
also easily turns into a trash bin: “since there’s a block here, let’s compute something here, filter something here...”. As a result, code becomes unreadable: from the outside it looks like a side step, but inside it’s half the business logic. A good rule: if removing also changes the program’s result, then you’re using also for the wrong purpose. Keep also for printing, logging, and light diagnostics.

Mistake #3: blocks that are too long and loss of readability.
Scope functions should make code easier to read, not create a “nesting doll of lambdas.” If your block in let/run/apply/with/also has grown to 15 lines, you almost certainly have logic that deserves its own function with a proper name. And yes, this is exactly the moment when “one extra line of code” makes the program clearer rather than worse.

Mistake #4: confusion due to nested it.
When you write something.let { ... map { ... } ... }, you get two its, and your brain starts working like a compiler: “okay, which it is where right now?”. Kotlin doesn’t forbid it, but it’s hard to read. In such places it’s better to name parameters explicitly: let { text -> ... }, map { token -> ... }. That’s not “verbosity”; it’s insurance against logical mistakes.

Mistake #5: using with and run interchangeably for no reason.
Both with(obj) {} and obj.run {} give you this and return the block result. If today you use one style and tomorrow the other, the code starts to look “choppy.” Pick a habit: either prefer obj.run {}, or prefer with(obj) {}— and stick to it at least within a single project. That way the reader won’t have to remember every time why there’s suddenly a with here.

1
Task
Kotlin SELF, level 28, lesson 0
Locked
Nickname check
Nickname check
1
Task
Kotlin SELF, level 28, lesson 0
Locked
Last character
Last character
1
Task
Kotlin SELF, level 28, lesson 0
Locked
Status screen
Status screen
1
Task
Kotlin SELF, level 28, lesson 0
Locked
Sorting Diagnostics
Sorting Diagnostics
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION