JavaRush /Courses /Kotlin SELF /Scope function mistakes: result, it, and unnecessary magi...

Scope function mistakes: result, it, and unnecessary magic

Kotlin SELF
Level 28, Lesson 4
Available

1. When scope functions hurt: syntactic sugar is sticky too

Kotlin scope functions (let, run, apply, also, with) give you compact code and a pleasant “pipeline” style. But they have a downside: the code can look correct, compile without complaints—and still do something other than what you expected.

Let’s look at the three most common groups of problems: losing the result (you chose a scope function that returns “the wrong thing”), confusion due to it/this inside nested lambdas, and unnecessary “magic” when the chain starts to look like an incantation.

Scope functions are like spices: a pinch makes the dish better, but half the jar turns borscht into an experiment on students. In Kotlin they really do simplify code, but the cost is “hidden” decisions: what the expression returns, what the resulting type is at the moment, which it is meant, where we computed something but forgot to use the result.

The problem is that the compiler usually does not complain. It honestly does exactly what you wrote. You just wrote something other than what you thought. So today we will learn to catch three classes of errors: losing the result (you chose a scope function that returns “the wrong thing”), confusion due to nested it/this, and unnecessary “magic” when the chain looks pretty but reads like a spell in an ancient JVM dialect.

2. Mistake #1: losing the result because of the wrong scope function

The most common and the most insidious mistake: you are sure “now I’ll compute and return,” and Kotlin is like: “sure, I’ll return… just not that, but the object everything was happening on.” The culprits are apply and also, because they return the object itself, not the result of the last expression in the block. This is great for configuring an object, but bad for computations.

So you don’t have to guess, keep a mini table in your head (we already discussed it, but now it’s needed as a first-aid kit):

Function Inside the block Returns
let
it
block result
run
(as obj.run { })
this
block result
with(obj) { }
this
block result
apply
this
the object itself
also
it
the object itself

A classic: apply on StringBuilder when you expected a String

Let’s start with an example that has wrecked the nerves of about half of humanity (the other half just hasn’t tried yet).

fun main() {
    val text = StringBuilder().apply {
        append("A")
        append("B")
    }

    println(text) // AB (in fact this will be the contents of the StringBuilder, but the type is StringBuilder)
}

When printed, it looks “as if everything is fine,” because StringBuilder prints nicely. But then you suddenly want to do text.uppercase()—and you get a compilation error, because uppercase() exists on String, while text is a StringBuilder.

The correct approach: if you need the result of a computation, use run and return toString() as the last expression.

fun main() {
    val s: String = StringBuilder().run {
        append("A")
        append("B")
        toString()
    }

    println(s) // AB
}

The key idea here: run is “do several actions in the object’s context and return whatever I put last.”

“I sorted the list in apply—why didn’t it get sorted?”

This is a special kind of pain because it looks logical, and logic can be a traitor sometimes.

fun main() {
    val xs = listOf(3, 1, 2).apply {
        sorted() // I kind of called sorted...
    }

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

Why? Because:

  • listOf(...) creates an immutable (read-only) list.
  • sorted() does not modify the list; it returns a new one.
  • apply returns the original object—that is, the original list.
  • You computed the result of sorted()… and threw it away.

If you want “take a list → get a new list,” that is a job for let:

fun main() {
    val xs = listOf(3, 1, 2).let { it.sorted() }

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

Here let honestly returns the block result.

“I filtered in also—why didn’t anything change?”

also is for side effects: logging, printing, diagnostics. It is almost always bad for changing the meaning of data in a chain.

fun main() {
    val tokens = listOf("kotlin", "", "is").also {
        it.filter { s -> s.isNotBlank() } // threw away the result again
    }

    println(tokens) // [kotlin, , is]
}

The right solution: filtering is a transformation, so use let (or just filter without a scope function).

fun main() {
    val tokens = listOf("kotlin", "", "is")
        .filter { it.isNotBlank() }

    println(tokens) // [kotlin, is]
}

But this is where also can and should be used—as diagnostics, without changing the result:

fun main() {
    val tokens = listOf("kotlin", "", "is")
        .filter { it.isNotBlank() }
        .also { println("tokens = $it") } // tokens = [kotlin, is]

    println(tokens.size) // 2
}

Where apply is actually great and why it’s not let

apply is an excellent “create and configure” tool. For example, when initializing mutable collections, it makes code compact and readable: you create a MutableMap and immediately fill it. This is exactly the scenario where “return the same object” is the right contract.

3. Mistake #2: confusion between it and this in nested blocks

The second big problem appears when scope functions start nesting into each other. In Kotlin lambdas are normal—we’ve long been writing map { ... }, filter { ... }, groupingBy { ... }. But when you add let { ... }, then another let { ... } inside it, and then the cherry on top—filter { ... }—you end up with three different “implicit” parameters on one screen. And they are all called it.

At this point the brain usually pretends it is a garbage collector: “I’ll figure it out later; for now I’ll just skip it.”

Example: “double it” in a Text Analyzer pipeline

Let’s imagine you are writing a pipeline “fast, one line, pretty.” Here is what you get:

fun main() {
    val input = " Kotlin   kotlin  "
    val tokens = input.let {
        it.trim().lowercase().split(" ").filter { it.isNotBlank() }
    }

    println(tokens) // [kotlin, kotlin]
}

This code works. But it’s unpleasant: it inside let is a string, while it inside filter is already a token. That is not obvious at a glance. Today you understand it, tomorrow you’re tired, the day after tomorrow you accidentally add one more level of nesting—and that’s it, hello “why doesn’t it compile.”

The solution is simple and adult: name the parameters.

fun main() {
    val input = " Kotlin   kotlin  "
    val tokens = input.let { text ->
        text.trim()
            .lowercase()
            .split(" ")
            .filter { token -> token.isNotBlank() }
    }

    println(tokens) // [kotlin, kotlin]
}

Now even if you open this code a month later, you will understand what is happening.

Can we avoid nesting entirely?

Yes, and it is often the best option. If you have a pipeline, it’s pleasant to read “top to bottom” without mini stair-steps. In our previous lectures, we arrived at exactly this “chain of steps” style, where each step is either a standard operation or a separate function.

fun main() {
    val input = " Kotlin   kotlin  "

    val tokens = input
        .trim()
        .lowercase()
        .split(" ")
        .filter { it.isNotBlank() }

    println(tokens) // [kotlin, kotlin]
}

Scope functions are not needed here at all. And that’s fine! The most “professional” scope function is the one you did not use because it reads better without it.

A this problem: “which this is it right now?”

When you start actively using run/apply/with, another trap appears: this stops being “me and my function” and becomes “the current receiver object.” This is great while there is only one receiver. But if you end up with nested receivers (for example, you build a string in StringBuilder().apply { ... }, and inside you do something else with run), it is easy to lose your bearings.

So here is a very practical rule: if the block is small (1–3 lines), this inside apply/run reads great; if the block grows, nesting appears, or there are multiple objects—either name parameters via let { x -> ... } or extract the chunk into a separate function. Not because “that’s how it must be,” but because you’re not a robot (and that’s fine, even if it’s a little insulting).

4. Mistake #3: unnecessary “magic” in chains

Scope functions are often tempting: “look, I can do it without a single variable, in one chain, and I’ll even insert also for logging!” Then you open it a week later and realize it’s not a chain, it’s macrame.

Even in Kotlin’s official style conventions, scope functions are mentioned as a separate topic of choice and readability: you should use them deliberately, not by the “the more, the more Kotlin-ish” principle.

Symptom: a block inside let/run is too long

If inside let you suddenly have branching, several intermediate variables, println, and you yourself can’t briefly say “what this block does,” then the scope function is no longer helping—it is hiding complexity.

Suppose you format top-N and cram everything into let:

fun main() {
    val freq = mapOf("kotlin" to 2, "java" to 1)

    val report = freq.let {
        val top = it.entries.sortedByDescending { e -> e.value }.take(1)
        val line = top.joinToString { e -> "${e.key}: ${e.value}" }
        "TOP:\n$line"
    }

    println(report) // TOP: kotlin: 2
}

This is still tolerable, but already borderline. When you have 3–4 of these blocks in a row, the code turns into “why is there even a let here?”

A cleaner style for our Text Analyzer is to extract steps into functions and keep the chain linear. We did this in the previous lecture: the “magic” disappears because each link is a function name, not an anonymous block.

Symptom: also does important work

By meaning, also is “and also this.” That is, you do the main chain and, in parallel, perform a side effect: printing, logging, measuring time, checking. If inside also you change data or make a decision, you are writing code that looks like “just logging” but actually affects the result. That’s a bad deal: you save 2 lines but pay with an hour of debugging.

Here is a bad example (important logic is hidden in also):

fun main() {
    var limit = 2

    val top = mapOf("a" to 1, "b" to 5, "c" to 3).entries
        .sortedByDescending { it.value }
        .also { limit = 1 } // suddenly change limit
        .take(limit)

    println(top.map { it.key }) // [b]
}

This works, but reads like “who swapped the variable at night?” If you need to change limit, do it explicitly before the chain or pass it as a parameter.

And here is a good also: diagnostics only; it does not change the result.

fun main() {
    val top = mapOf("a" to 1, "b" to 5, "c" to 3).entries
        .sortedByDescending { it.value }
        .also { println("sorted keys = ${it.map { e -> e.key }}") } // sorted keys = [b, c, a]
        .take(2)

    println(top.map { it.key }) // [b, c]
}

Symptom: scope functions “just in case”

Sometimes you can see a style like this:

val result = input
    .let { it.trim() }
    .let { it.lowercase() }
    .let { it.split(" ") }

This is not an error, but it’s noise. Here let does not add meaning: normal calls already read linearly. You need let when you change the call form (for example, you want to insert a callable reference ::normalizeText), or when you work with nullable (?.let), or when you want to pin an intermediate result with a name inside the block.

Mini check: how to read a chain and catch mistakes early

There is good news: most scope function mistakes can be caught if you read code a bit more slowly than usual (yes, this sounds like “just be careful,” but there is a specific technique).

When you see . and a scope function, ask yourself two questions as you read: “what is the context object in the block?” and “what does this call return?”. If you can’t answer in 2–3 seconds, the code is already on the edge of readability, and it’s better to simplify it.

Next, it is useful to look at the result type after each step. In IntelliJ IDEA this is basically cheating: hover over the expression and the IDE will tell you the type. If after apply you expected a String, but the IDE shows StringBuilder, you just caught a bug before running (and that’s the best kind of debugging—when nothing crashed).

And finally: if it feels like the chain has become “too smart,” try rewriting it with 2–3 intermediate vals with normal names (normalized, tokens, freq, top). If it became easier to read, then that’s how it should be. Scope functions should serve readability, not a “who can be shorter” competition.

5. Typical mistakes when working with scope functions

We keep the format of this block so that you can quickly scan it and say: “oh yeah, I’ve done that, and I was embarrassed by it”—in a good sense.

Mistake #1: using apply/also where you need the result of a computation.
When you want to “get a new value” but you write apply, you will almost certainly lose the result: sorted(), filter(), map() inside the block will be computed but go nowhere, because apply returns the original object. In such places you typically need let, run, or just a direct call without a scope function.

Mistake #2: “I called sorted()/filter() inside the block, so the collection changed.”
Many collection operations in Kotlin return a new list rather than modifying the original. If you didn’t assign the result (or return it from let/run), then you simply “thought about sorting”—and that’s it. In Text Analyzer this is especially noticeable with tokens: forget to apply filtering and you get empty words in the frequencies.

Mistake #3: nested it’s that make it unclear what is being processed.
When a single expression contains let { it ... filter { it ... } }, you force the reader to keep two different its in mind. The compiler will cope, but a human won’t always. Fix it either by explicit names (text, token, entry) or by flattening the code into several steps.

Mistake #4: big blocks inside scope functions that hide the algorithm.
Scope functions work best as short “adapters”: 1–5 lines. When the block is already a mini-program (branching, multiple vals, multiple meanings), the code stops being a linear pipeline. In such cases it’s better to extract the block into a named function and keep only its name in the chain—this gives you readability without losing structure.

Mistake #5: using also for important logic and side effects that affect the result.
also should be “on the side”: print, log, check, but not change the meaning of data. If inside also you change variables, mutate collections, or decide how many elements to take in take(n), you create code that looks like “diagnostics” but actually changes behavior. This is an almost guaranteed source of surprises.

Mistake #6: trying to make code “more Kotlin-ish” by adding let everywhere.
Sometimes scope functions start being used as decorative style: “let’s add .let { it }.” This makes the code longer and noisier. A good sign of a healthy solution: if you remove the scope function and replace it with a normal call, the code does not get worse. If it gets better, the scope function was unnecessary.

1
Task
Kotlin SELF, level 28, lesson 4
Locked
Participant badge
Participant badge
1
Task
Kotlin SELF, level 28, lesson 4
Locked
Report sorting
Report sorting
1
Task
Kotlin SELF, level 28, lesson 4
Locked
Readable lambdas
Readable lambdas
1
Task
Kotlin SELF, level 28, lesson 4
Locked
Logs without side effects
Logs without side effects
1
Survey/quiz
Scope functions and Text Analyzer walkthrough, 28 level, 4 lesson
Unavailable
Scope functions and Text Analyzer walkthrough
Scope functions and Text Analyzer walkthrough
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION