JavaRush/Courses/Kotlin SELF/Smart cast after null checks

Smart cast after null checks

Open

1. Basic smart cast cases

When you’re just starting to write code, it feels like the compiler is an annoying teacher: “you can’t call .length because it’s a String?”. But in fact the compiler can be very friendly: if you prove to it that there is no null value, it will temporarily treat the variable as non-null and let you write normal code without ?..

This is called smart cast—automatic “type narrowing” inside a safe region. In Kotlin 2.x (the K2 compiler) there are more such situations, and the analysis is smarter, but the principle is still the same: the compiler starts “trusting” a variable only where the guarantee cannot suddenly disappear.

Imagine that String? is a box that might contain a string, or might contain emptiness. Smart cast is the moment when you look into the box, see a string, and say: “OK, as long as I haven’t closed my eyes and nobody has touched the box, we’ll assume the string is definitely there.”

After if (x != null)

The nicest scenario: you check a variable for null, and inside the if branch the compiler allows you to use it like a normal non-null variable. At that point the code becomes much cleaner: instead of chains of ?. you write regular method and property calls.

That’s exactly why people like smart cast: it makes code safe, readable, and free of extra “punctuation”. It also helps you think in terms of program logic: “in this branch, the value definitely exists”.

Example: function parameter (the most reliable case)

fun printNicknameInfo(nickname: String?) {
    if (nickname != null) {
        println("Nickname length: ${nickname.length}")  // Nickname length: 3 (for example)
        println("Uppercase: ${nickname.uppercase()}") // Uppercase: NEO
    }
}

Why does this usually work perfectly? Because the parameter nickname inside the function doesn’t “jump around” by itself: you don’t reassign it. The compiler sees the check nickname != null and assumes that inside the if block you can work with it as a String.

Example: guard clause (early exit)—smart cast without nesting

fun formatNickname(nickname: String?): String {
    if (nickname == null) return "(anonymous)"
    return nickname.uppercase()
}

After return, only the branch where nickname is definitely not null remains, and the compiler understands that. This style is often easier to read than a big if/else because the code flows “straight” instead of going into a branching maze.

Smart cast and &&

Earlier you already saw how && lets you write more concisely: if the left side is false, the right side is not evaluated. For null-safety this is a real gift: the expression x != null && x.length > 3 is safe because x.length will be evaluated only when x is not null.

And the compiler takes this into account: in the second part of the condition x is often smart-cast to non-null. So && works like a mini-document: “first check existence, then use it”.

Example: null check + length check

fun isGoodNickname(nickname: String?): Boolean {
    return nickname != null && nickname.length >= 3
}

fun main() {
    println(isGoodNickname(null))     // false
    println(isGoodNickname("ab"))     // false
    println(isGoodNickname("neo"))    // true
}

Here nickname.length doesn’t require ?. because the right side runs only when nickname != null.

Example: “empty string” vs null

fun main() {
    val raw = "   "
    val nickname: String? = raw.trim().ifEmpty { null }

    if (nickname != null && nickname.isNotBlank()) {
        println("Nickname: ${nickname.uppercase()}")
    } else {
        println("Nickname not set") // Nickname not set
    }
}

Notice the idea: null means “no value”, while " " means “there is a value, but it’s junk”. That’s why sometimes you normalize the string first and only then rely on smart cast.

3. Sometimes smart cast doesn’t work

This is where the classic beginner situation starts: “But I checked it! Why doesn’t the compiler believe me?!”

Answer: the compiler believes you only when the variable is stable, meaning it can’t be changed between the check and the use. The most common enemy is var. You can change a var yourself in code, and sometimes it can be changed via a function call or a closure (yes, that moment when the variable “lives” in an outer scope and someone inside a function touches it).

From the compiler’s point of view the logic is: “You checked nickname != null. But if it’s a var, who guarantees that one line later it didn’t become null?” And the compiler, like a cautious person, prefers to play it safe.

Example: a var can be changed right inside if

fun main() {
    var nickname: String? = "neo"

    if (nickname != null) {
        nickname = null

        println(nickname.length) // you can't write this: after reassignment nickname can be null again
        println(nickname) // null
    }
}

Here it’s obvious even to a human that nickname.length after nickname = null is a bad idea. The compiler simply won’t let you do something foolish.

Example: a function changes the variable

fun main() {
    var nickname: String? = "neo"

    fun dropNickname() {
        nickname = null
    }

    if (nickname != null) {
        dropNickname()

        println(nickname.length) // the compiler won't allow it: nickname could have become null
        println(nickname?.length)   // null
    }
}

For the compiler, dropNickname() is a “black box”. Even if you now know what the function does, tomorrow you (or a colleague) might add logic there and the guarantee disappears. Kotlin prefers safety.

Example: “checked one thing, use another” (a subtle logical trap)

fun main() {
    var nickname: String? = "neo"

    val ok = nickname != null
    if (ok) {
        // Inside this block nickname does not automatically become String,
        // because we checked ok, not nickname directly.
        println(nickname?.length) // 3
    }
}

In Kotlin 2.x the compiler got better at discovering such proofs from code in some cases, but as a rule of thumb for beginners it’s better to think like this: smart cast works most easily when the check is right next to the use.

4. What to do if smart cast didn’t work

When smart cast doesn’t kick in, it’s very easy to “lose it” and slap on !!. It’s like covering the warning light on your dashboard with tape: the problem disappears visually, but the car can still start smoking sadly.

It’s much better to have three solid strategies: take a snapshot into a val, use ?./?:, or restructure the code so the check is closer to the use.

Strategy A: snapshot—store the value in a val

fun main() {
    var nickname: String? = "neo"

    val snapshot: String? = nickname
    if (snapshot != null) {
        println(snapshot.length) // 3
    }
}

Why does this work? Because snapshot is a val. It can’t be reassigned, so within the if block the compiler treats it as stable and happily smart-casts it.

Strategy B: safe call ?. + Elvis ?:

fun main() {
    val nickname: String? = null

    val len: Int = nickname?.length ?: 0
    println("Nickname length: $len") // Nickname length: 0
}

This is a good style when you need a one-liner computation: if the value exists, take the length; if not, take a default.

Strategy C: restructure with an early return

fun printUpper(nickname: String?) {
    if (nickname == null) {
        println("Nickname not set")
        return
    }

    println(nickname.uppercase())
}

fun main() {
    printUpper(null)    // Nickname not set
    printUpper("neo")   // NEO
}

Here you don’t force the compiler to be a telepath: you explicitly separate the null branch, and then work with a non-null value.

5. How the compiler thinks: a diagram and a table

Sometimes it helps to imagine the compiler as a very picky bouncer at the door of the “.length()” club. It lets in only those who have proof “I’m not null”, and only if nobody can swap the ID on the way.

This is not an exact internal model of the compiler, but as a “mental picture” it’s useful.

flowchart TD
    A["There is a variable x: T?"] --> B{"Did we check x != null?"}
    B -- "no" --> C["Need ?. / ?: / if / return"]
    B -- "yes" --> D{"Is x stable?"}
    D -- "yes (val / parameter / local snapshot)" --> E["Smart cast: x is treated as T inside the scope"]
    D -- "no (var can change)" --> F["The compiler disallows x.member without safe access"]

And one more small table to reinforce the “stability” idea without diving into compiler magic:

What we check for null Does smart cast usually work? Why
Function parameter p: String? Yes A parameter can’t be reassigned; it’s stable within the function body
Local val x: String? Yes val doesn’t change
Local var x: String? Sometimes As long as the compiler is sure x won’t change before use
A var that can be modified by a function/closure Often no The compiler can’t guarantee that x won’t become null

6. Mini app: user questionnaire

Let’s build a small console app that asks the user for a nickname (optional) and then prints a greeting. We’ll deliberately write the code so we hit both a “pleasant” smart cast and a situation where it doesn’t work, plus a fix via snapshot.

The idea is simple: the user enters a string. If after trim() it’s empty, we treat it as no nickname, i.e. null. This is exactly the kind of case where null makes sense: “the user didn’t provide a value”.

Version 1: linear code with classic smart cast

fun main() {
    print("Enter nickname (Enter to skip): ")
    val raw = readln().trim()

    val nickname: String? = if (raw.isEmpty()) null else raw

    if (nickname != null) {
        println("Hi, ${nickname.uppercase()}!") // Hi, NEO! (for example)
    } else {
        println("Hi, stranger!")              // Hi, stranger!
    }
}

Here nickname is a val, so inside if (nickname != null) the compiler calmly treats nickname as a String.

Version 2: add a var and a “suspicious” function

Now imagine we want to allow “resetting the nickname” via some logic (for example, by a special command). This is an artificial example, but it demonstrates the reason smart cast is refused very well.

fun main() {
    print("Enter nickname (Enter to skip): ")
    val raw = readln().trim()

    var nickname: String? = if (raw.isEmpty()) null else raw

    fun maybeDropNickname() {
        // Imagine there is complex logic here.
        // For this example, the "complex logic" is just a reset.
        nickname = null
    }

    if (nickname != null) {
        maybeDropNickname()

        // println(nickname.length) // the compiler won't allow it
        println("Nickname length: ${nickname?.length}")  // Nickname length: null
    }
}

What matters here is the fact itself: since nickname is a var and there’s a function that can change it, the compiler won’t let you access it like a String after the check.

Version 3: fix it with a snapshot (and keep readability)

fun main() {
    print("Enter nickname (Enter to skip): ")
    val raw = readln().trim()

    var nickname: String? = if (raw.isEmpty()) null else raw

    fun maybeDropNickname() {
        nickname = null
    }

    val snapshot = nickname
    if (snapshot != null) {
        // Even if nickname changes later, snapshot won't change anymore
        println("Hi, ${snapshot.uppercase()}!") // Hi, NEO!
    }

    maybeDropNickname()
    println("nickname now: $nickname")           // nickname now: null
}

Meaning: if you need to “freeze the value at the moment of the check”, take a snapshot. This is a very common technique in real code, even once you move on to more complex programs.

7. Common mistakes when using smart cast with null

Mistake #1: thinking that the check x != null makes x safe “forever”.
Smart cast applies within a specific scope, usually inside an if block, and only while the compiler is sure the value can’t be changed. Once you leave the block or change the variable, the guarantee is gone—and that’s normal: the code should honestly reflect where you have proof and where you no longer do.

Mistake #2: “fixing compilation” with !! because “I definitely know”.
!! is not “please make it compile”; it’s “if this is null, let the program crash”. Sometimes you truly need that behavior, but in most learning tasks it’s just a way to hide a bug. Usually it’s better to use if, ?., ?:, or a snapshot in a val, and make the null scenario explicit.

Mistake #3: separating the check and the use too far from each other.
When there are ten lines, function calls, or nested conditions between if (x != null) and using x, it’s harder for the compiler to guarantee safety and harder for a human to read. A good style is to either use the value immediately inside the branch, or store a snapshot in a val, or use an early return so the code stays linear.

Mistake #4: trying to “talk the compiler into it” instead of helping it with code structure.
Beginners sometimes start moving braces around, adding extra checks, or writing “magic” constructs hoping a smart cast will suddenly appear. Usually it’s better not to guess, but to pick a simple strategy: snapshot, ?./?:, early return. Kotlin values clear code and happily cooperates with it.

1
Task
Kotlin SELF,  11 level,  4 lecture
Locked
Profile nickname
Profile nickname
1
Task
Kotlin SELF,  11 level,  4 lecture
Locked
Bot greeting
Bot greeting
1
Task
Kotlin SELF,  11 level,  4 lecture
Locked
Password validation
Password validation
1
Task
Kotlin SELF,  11 level,  4 lecture
Locked
Comment snapshot
Comment snapshot
Комментарии
  • популярные
  • новые
  • старые
Для того, чтобы оставить комментарий Вы должны sign in
This page has no comments yet