JavaRush /Courses /Kotlin SELF /Nullable chains without cascaded ifs: ?.let, Elvis ?:, ru...

Nullable chains without cascaded ifs: ?.let, Elvis ?:, run {}

Kotlin SELF
Level 28, Lesson 1
Available

1. Why nullable chains are needed

When you’re just starting to work with null, the most natural move is to write checks if (x != null) and do the required work inside. That’s fine: millions of programmers thought that way before us, and some still do (sometimes even successfully). But as soon as a second processing step appears, then a third, fourth—your ifs start to “grow downward”, and the code turns into a ladder you want to prop up with a stool.

Imagine a typical input: the user entered a string → we trimmed it → tried to parse a number → validated the range → computed the result. If you write this with nested ifs, you’ll get many indentation levels and a constant “what if there’s null here?”. Nullable chains let you write the same logic linearly: “here’s a step, here’s a step, here’s a step, and if at some step there’s no value— here’s the fallback”.

Our goal today is to learn two basic combos:

  • x?.let { ... } — execute the block only if x is not null, and inside the block it is already not nullable.
  • x?.let { ... } ?: ... — a linear way to write “if it worked—take the result; otherwise—use the fallback scenario”.

And yes, Kotlin explicitly positions let as one of the functions that help “simplify expressions” and keep processing close to the data.

2. ?.let { ... }: process only if a value exists

let is a scope function where the object is available as it, and the result is the last expression of the block. The key detail specifically for nullable work: when you write x?.let { ... }, the block runs only if x != null. And inside let, Kotlin already understands that it is not null, so it allows you to call methods and properties without additional checks.

Let’s start with a very simple example: read a number; if it works—print the square. If it doesn’t—do nothing at all.

fun main() {
    val raw = readln()
    val n: Int? = raw.toIntOrNull()

    n?.let { value ->
        val sq = value * value
        println("square = $sq")      // if you entered 7 -> square = 49
    }
}

Note: here we do not decide what to do in the null case. That can be OK when the action is truly optional (for example, debug printing, “we’ll try—if it doesn’t work, oh well”). But in real scenarios you more often need to necessarily have a result—and then ?.let alone is not enough; you need a combo with Elvis ?:.

Another example: we have a string and want to bring it to a “normal form” (trim + lowercase), but only if the string exists at all.

fun main() {
    val maybeText: String? = readln().takeIf { it.isNotBlank() }

    val normalized: String? = maybeText?.let { it.trim().lowercase() }

    println(normalized) // if you entered "  HeLLo " -> hello
                       // if you entered empty/spaces -> null
}

Here normalized is also nullable. And that’s logical: if there’s no input, there’s nothing to normalize. But if later in the code you need a non-nullable text (for example, for a report), it’s time to “close” null into a specific behavior.

3. The pattern ?.let { ... } ?: fallback

In a regular if-else, you explicitly write two branches. Elvis ?: does the same, but as an expression: on the left is the “main option”, on the right is the “backup”. And ?.let is convenient to put on the left so the main option is computed only when a value is present.

Meaning of the pattern:

  • left: “if x is not null—compute the result”
  • right: “otherwise—return/compute the fallback”

Example: read a number. If the input is valid—double it. If not—use 0.

fun main() {
    val raw = readln()
    val n: Int? = raw.toIntOrNull()

    val doubled: Int = n?.let { it * 2 } ?: 0

    println("doubled = $doubled") // entered 21 -> doubled = 42
                                 // entered "kitten" -> doubled = 0
}

Key point: doubled is not nullable, because to the right of ?: we provided a concrete value 0. That is, we “closed” the uncertainty.

A very common practical story: you want to treat an empty string as “absent”, not as real input. Then the combo takeIf → ?.let → ?: works great.

fun main() {
    val token: String? = readln()
        .trim()
        .takeIf { it.isNotEmpty() }

    val lower: String = token?.let { it.lowercase() } ?: "<empty>"

    println(lower) // entered " Kotlin " -> kotlin
                   // entered "" -> <empty>
}

Here takeIf turns a “bad value” into null, and ?: turns null into a clear default.

So you don’t have to keep all of this in your head as “magic spell”, let’s lock in a mini table:

Form When the block runs What is returned Typical meaning
x?.let { ... }
only if x != null the block result, but the nullable chain can continue “try to process if present”
x?.let { ... } ?: fallback
the block—if x exists; otherwise fallback always a value (usually non-null) “normal result or a backup”

4. Elvis ?: as a “contract”: a result is required

Many people initially perceive the Elvis operator ?: as “just replace null with 0”. And yes, that’s one option. But a more mature view is that it’s a tool that helps you fix the contract: “at this point the program must obtain a value”.

For example, you write a function readTopN(): Int, and it must return a normal number (not nullable), because you want to use topN further without checks. Then inside you can work with Int?, but at the end—close null with a default.

fun readTopN(): Int {
    val raw = readln().trim()
    val n: Int? = raw.toIntOrNull()

    return n?.takeIf { it > 0 } ?: 5
}

fun main() {
    val topN = readTopN()
    println("topN = $topN") // entered 3 -> topN = 3
                            // entered -10 -> topN = 5
                            // entered "abc" -> topN = 5
}

In this example, one detail matters: takeIf { it > 0 } can also return null if the number doesn’t fit. And that’s good: we honestly say “doesn’t fit—so it’s as if there’s no value”, and then Elvis chooses the default.

A small but useful fact: in Kotlin, Elvis is often used not only for values, but also to do an early return or throw—because the right side can be any expression. We won’t dive deep into exceptions now, but “early exit” will be useful today.

5. run { ... } as a multi-line fallback

Sometimes it’s not enough to put 0 or "<empty>" on the right of ?:. Sometimes you want to: show a message to the user, choose a default value, maybe prepare something—and only then return the result. And here many beginners try to write something odd like:

// don't do this; it's an example of "how you can get confused"
val x = n ?: println("bad") // println returns Unit, and things start breaking

To do several actions on the right of Elvis, use run { ... }. It’s a regular function that executes a block and returns the last expression. So it’s a perfect “container” for a multi-line fallback.

fun main() {
    val n: Int? = readln().toIntOrNull()

    val value: Int = n ?: run {
        println("Not a number; I'll use the default value") // message
        10                                                   // last expression = result
    }

    println("value = $value") // entered 5 -> value = 5
                              // entered "hi" -> message + value = 10
}

Note: run { ... } here is not an “object scope function”, it’s just a block expression. We don’t access this; we don’t need it. We need “several lines of code that compute a value”.

The same trick works great for an “early exit” from main if the user didn’t provide required data.

fun main() {
    val textOrNull: String? = readln().trim().takeIf { it.isNotBlank() }

    val text: String = textOrNull ?: run {
        println("Empty input—nothing to analyze. Exiting.")
        return
    }

    println("We will analyze: $text") // if we got here, text is definitely not empty
}

Notice how readable this becomes: on the left we describe the “normal path”, on the right—“what we do if there’s no data”. No nested ifs, no extra indentation level.

6. A starter for a console Text Analyzer

Now we’ll take a small but very practical step toward the future Text Analyzer: learn to read required text and optional settings so that main looks like a scenario, not a “validation factory”. We’re not counting frequencies or building top-N yet—that will be the next lecture. But we’ll prepare input so it’s easy to add a pipeline later.

First, let’s make a utility: read a non-blank string or return null. It’s small, but it already removes half the pain.

fun readNonBlankLineOrNull(prompt: String): String? {
    print(prompt)
    return readln().trim().takeIf { it.isNotBlank() }
}

fun main() {
    val text: String = readNonBlankLineOrNull("Enter text to analyze: ") ?: run {
        println("Empty. So, no analysis today.")
        return
    }

    println("OK, text accepted: \"$text\"")
}

Now let’s add a “setting”: the user can enter topN, or can leave it blank. If blank or garbage—use a default, for example 5. Here we’ll need ?.let and Elvis.

fun readIntOrNull(prompt: String): Int? {
    print(prompt)
    return readln().trim().toIntOrNull()
}

fun main() {
    val text: String = readNonBlankLineOrNull("Enter text to analyze: ") ?: run {
        println("Empty. So, no analysis today.")
        return
    }

    val topN: Int = readIntOrNull("How many words to show (Enter = 5): ")
        ?.takeIf { it > 0 }
        ?: 5

    println("Text: \"$text\"")         // example: Text: "kotlin is great"
    println("topN = $topN")            // example: topN = 5
}

And here it’s important to feel the “taste” of the approach: topN at the output is not nullable, which means later, in the next lectures, you’ll calmly use topN without if (topN != null).

If you just thought “can it be shorter?”, then yes, it can. But our goal is not the minimal line length; it’s a readable scenario.

7. Where to close null, and where to improve readability

A very typical beginner mistake is to accidentally drag nullable too far, and then be surprised that Kotlin “keeps asking for ?. everywhere”. Kotlin isn’t being picky: it honestly reminds you that you yourself haven’t decided what “no value” means.

There are two healthy options.

  • Option 1. You consciously keep T? further along, because “absence of a value” is a normal state. For example, in settings: the user may not specify a filter, and then there is no filter. In this case, nullable is part of the contract, and that’s fine.
  • Option 2. You “close” nullable into a specific behavior: a default value, a message + return, or “fail” (but that’s already closer to require/check and exceptions—we’ll be careful today).

A practical tip: if a function is meant to “produce a result to continue the scenario”, try to return a non-nullable value, and hide all nullable stuff inside. For example:

fun readPositiveIntOrDefault(prompt: String, default: Int): Int {
    print(prompt)
    val n: Int? = readln().trim().toIntOrNull()
    return n?.takeIf { it > 0 } ?: default
}

fun main() {
    val topN = readPositiveIntOrDefault("Top N (Enter = 5): ", default = 5)
    println("topN = $topN") // Enter -> topN = 5
}

Nullable chains are great as long as they stay as chains of 2–4 steps. But Kotlin also lets you write 14 steps in one line. And that’s where “cryptograms” begin: the code is formally correct, but it’s scary to touch.

There’s a simple criterion: if inside let you start writing a mini-algorithm or a second nested let appears—it’s better to stop and make the code a bit more “flat”.

For example, this is hard to read (especially for a beginner):

fun main() {
    val result = readln().trim().takeIf { it.isNotBlank() }
        ?.let { it.toIntOrNull() }
        ?.let { it * 10 }
        ?: 0

    println(result)
}

Yes, it works. But it’s not immediately clear at each step what’s happening. It’s better to give values names:

fun main() {
    val raw: String = readln()
    val token: String? = raw.trim().takeIf { it.isNotBlank() }
    val n: Int? = token?.toIntOrNull()

    val result: Int = n?.let { value -> value * 10 } ?: 0

    println(result) // entered "7" -> 70
}

This version is a couple of lines longer, but you can explain it out loud without stumbling.

One more small lifehack: if the chain contains it, and you feel that “it is not the same anymore”— just name the parameter.

val message: String = readIntOrNull("Enter age: ")
    ?.let { age -> "You are $age years old" }
    ?: "Age not recognized"

println(message)

With age it’s easier to read than with the “fifth it of the day”.

8. Common mistakes

Mistake #1: write x?.let { ... } and forget that the result can still be null.
This happens all the time: a person is happy they removed an if, and then is surprised that later they still need ?. and ?:. The fix is simple: if you need a non-nullable result later, close the chain immediately with the Elvis operator and choose a concrete behavior.

Mistake #2: use !! where you need Elvis.
!! means “I swear it’s definitely not null here, programmer’s honor”. Sometimes that’s justified, but with user input—almost never. If the value can be missing—prefer ?: with a default or ?: run { ...; return }. Then the program behaves predictably instead of crashing the first time someone enters “kitten” instead of a number.

Mistake #3: try to make the fallback multi-line without run { ... } and accidentally get Unit instead of a value.
When on the right of ?: you call println(...), you must remember that println returns Unit. So an expression like val x = n ?: println(...) either won’t compile, or will lead to weird types. If you need to print and return a value—wrap it in run { ... }.

Mistake #4: turn chains into a “magic conveyor belt” that nobody understands.
Scope functions and nullable chains are a readability tool, not a code-golf competition. If the chain got long, if it repeats and gets confusing, if you’re not sure what type is currently “flowing” through the pipe—stop, introduce intermediate vals or extract a step into a named function. Code should be clear not only to the compiler, but also to you tomorrow morning.

1
Task
Kotlin SELF, level 28, lesson 1
Locked
Clean input
Clean input
1
Task
Kotlin SELF, level 28, lesson 1
Locked
Smart discount
Smart discount
1
Task
Kotlin SELF, level 28, lesson 1
Locked
Name on the badge
Name on the badge
1
Task
Kotlin SELF, level 28, lesson 1
Locked
Echo with repetitions
Echo with repetitions
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION