JavaRush/Courses/Kotlin SELF/Smart cast and how to use it

Smart cast and how to use it

Open

1. Smart cast as a compiler promise

Smart cast is not magic and not telepathy. It’s a very pragmatic compiler decision: if it is sure that a value at some point in the code has a narrower type (for example, String instead of String?), then it lets you access it as that narrower type without an explicit as and without !!.

The key word is: sure. If there is even a small chance that the value might change between the check and the use, the compiler switches into “I don’t believe you, because later you’ll blame me” mode.

Let’s start with a situation where smart cast works perfectly, and it looks like “well, of course it should”:

fun printNameLength(name: String?) {
    if (name != null) {
        println(name.length) // ok: inside the block name is treated as String
    }
}

Here name is a function parameter (effectively a local reference), and within the if block the compiler can guarantee: if we got here, then name is not null.

2. Why smart cast sometimes doesn’t work

If you describe the problem in one sentence, it’s this: smart cast works only where the compiler is confident that the object is stable (won’t change behind its back).

Kotlin is “suspicious” of several situations: mutable variables (var), object properties (obj.prop), especially if they can have a custom getter, and cases where the value is used inside a lambda and could be changed somewhere else.

Sometimes it feels unfair: you can see with your eyes that “nothing changes,” but the compiler can’t prove it. The logic is simple: the compiler doesn’t read your mind; it reads the code and models risks.

var and “what if someone changes it?”

var is a favorite source of pain, because it literally means: “this can be changed.” And if the compiler sees that a variable in principle can change, it often refuses to smart-cast it across a longer stretch of code.

Let’s look at an example close to real life: suppose in a CLI app we have the “current input line” (not the best design, but it happens in learning projects).

var currentLine: String? = null

fun printCurrentLineLength() {
    if (currentLine != null) {
        // The compiler may complain: currentLine is var, it can become null
        println(currentLine.length)
    }
}

Why might Kotlin complain? Because currentLine is read twice: once in the condition, and a second time in println. Between those reads, in theory, any code could have run (in a big project — an event handler, another function that changed global state, etc.). Even if right now you have “no threads” and “nothing like that,” the compiler still follows the language contract, not the project’s current mood.

This is fixed with the classic technique: take a snapshot of the value into a local val.

var currentLine: String? = null

fun printCurrentLineLength() {
    val snapshot = currentLine
    if (snapshot != null) {
        println(snapshot.length) // ok: snapshot is val, stable in this block
    }
}

And this is an important habit: when in doubt, do one “controlled” read of a nullable value and then work only with a local val. It simplifies the code both for the compiler and for your future “me in two weeks.”

obj.property is an access call, not a “variable”

A very common trap: we checked user.name != null, and then we want to use user.name.length. It seems logical, but the compiler looks at it differently.

Key idea: user.name is a property, and property access doesn’t have to be “just reading a field.” It can be a getter call (including a custom one) that returns a new value each time. That’s why the compiler often doesn’t smart-cast user.name “for long.”

Minimal example:

class User(var name: String?)

fun printUserNameLength(user: User) {
    if (user.name != null) {
        // Often an error: smart cast is not applied to user.name
        println(user.name.length)
    }
}

Even if name is a plain var, the logic is the same: we read user.name twice. Between reads it can change (for example, someone assigned user.name = null).

The correct technique is, again, a snapshot:

class User(var name: String?)

fun printUserNameLength(user: User) {
    val name = user.name
    if (name != null) {
        println(name.length) // ok
    }
}

This is not a “hack,” it’s normal style: “read once → check → use.”

Why the compiler is especially cautious with a custom getter

To make it completely clear why the compiler doesn’t have to trust properties, imagine the getter is actually tricky:

class FlakyUser {
    val name: String?
        get() = if (System.currentTimeMillis() % 2L == 0L) "Neo" else null
}

fun printFlakyNameLen(u: FlakyUser) {
    if (u.name != null) {
        // Even a human can’t guarantee that the second call to u.name won’t produce null
        println(u.name.length)
    }
}

Here you can already see that “check and then use” doesn’t work, because u.name is effectively a function. The compiler behaves strictly so it can be equally safe in both simple and tricky cases.

Nullable + lambda: the value “escapes” your control

Next class of situations: you checked x != null, and then you use x inside a lambda. Sometimes it works, sometimes it doesn’t — and to a beginner it seems like Kotlin is just messing with you.

Why? Because a lambda is a separate piece of code that might execute not where you expect. The compiler must be sure that the variable won’t change before the lambda executes.

Example: suppose we format output via a function that accepts an action.

fun render(block: () -> Unit) {
    block()
}

fun printLenInsideLambda(text: String?) {
    if (text != null) {
        render {
            println(text.length) // sometimes smart-cast restrictions appear here
        }
    }
}

In practice, Kotlin 2.x (the K2 compiler) has improved in some smart-cast scenarios inside inline functions, because an inline function behaves as if the lambda is “called right here,” which makes it easier for the compiler to prove safety. But the basic principle remains: if the compiler can’t guarantee that the lambda will run exactly here and that the variable won’t change, it will be cautious.

The most reliable technique, which works almost always: take a snapshot before the lambda.

fun render(block: () -> Unit) {
    block()
}

fun printLenInsideLambda(text: String?) {
    val snapshot = text
    if (snapshot != null) {
        render {
            println(snapshot.length) // ok
        }
    }
}

Smart cast and try/catch: when information can be “reset”

Another point that sometimes surprises people: you seemingly “already knew” that a variable is not null, but inside try/catch or after some operations the compiler may give you the nullable type back.

We don’t need to dive deeply into the details now, but it’s useful to understand the principle: if inside try you assign another value to the variable (including null), the compiler must re-evaluate its type after that. That’s why the “snapshot into a val” style again works as universal insurance when you want to treat a value as stable.

3. Techniques for rewriting code to fit smart cast

Snapshot into a local val

This is the basic technique for almost all “smart cast impossible” situations: read the value exactly once and then work with it as a local val.

The formula is simple:

val snapshot = maybeNull
if (snapshot != null) {
    // use snapshot as non-null
}

This habit solves two problems at once: it makes the code clearer and eliminates repeated reads/getter calls.

Guard clause: early exit so everything after is non-null

Sometimes the best way to “convince” the compiler is to make the code more straightforward: if the value is null, we exit immediately. This is called a guard clause. The upside of this style: less nesting, fewer if “staircases,” and usually it’s easier for the compiler to apply smart cast to the remaining part of the function.

Let’s compare two variants.

Nested variant:

fun printUpper(text: String?) {
    if (text != null) {
        println(text.uppercase())
    }
}

Early-exit variant:

fun printUpper(text: String?) {
    if (text == null) return
    println(text.uppercase()) // text is treated as String
}

Psychologically, this style often reads easier: first we “filter out bad cases,” then we write the main scenario without constant ?..

requireNotNull / checkNotNull: explicit conversion T? -> T

When null means “this is a bug / contract violation,” you don’t have to coax smart cast — you can say it directly: “if it’s null — crash with a clear message.” For this there are requireNotNull and checkNotNull.

The semantic difference is: require* is usually about invalid arguments/input for a function, while check* is about an invalid state (an invariant is broken). In this lecture, the more important part is: both functions return a non-null value, so it’s a convenient and readable way to get a T.

Example:

fun tokenLength(token: String?): Int {
    val t = requireNotNull(token) { "Token must be provided" }
    return t.length
}

Here we’re not even asking smart cast to work: we explicitly performed the “conversion” from String? to String.

But it’s important not to overuse this. If token is user input that can be missing in a normal scenario, fail-fast may be too harsh: you’ll just crash the program instead of asking the user to enter the value again. Preconditions are good when null means “the programmer made a mistake” or “an internal contract was violated.”

4. Practice: the remove command and double-reading a search result

Imagine a learning CLI expense tracker. We have a repository that can find an expense by id. By the search contract the result may be absent — meaning it’s an Expense?.

A very simple model:

data class Expense(val id: Int, val title: String, val amount: Int)

fun findExpenseById(expenses: List<Expense>, id: Int): Expense? {
    return expenses.find { it.id == id }
}

Now a naive handler for the remove command:

fun handleRemove(expenses: MutableList<Expense>, id: Int) {
    if (findExpenseById(expenses, id) != null) {
        // Bad: we call the search again and hope the result is the same
        val title = findExpenseById(expenses, id).title
        println("Removed: $title")
    }
}

Here the problem isn’t only smart cast — it’s also logical: we search for the item twice. The list could change between calls, and even if not, it’s extra work.

Correct style: find once → store → then work with the “snapshot of the result.”

fun handleRemove(expenses: MutableList<Expense>, id: Int) {
    val found = findExpenseById(expenses, id)
    if (found == null) {
        println("Nothing to remove: id=$id")
        return
    }

    expenses.remove(found)
    println("Removed: ${found.title}") // Removed: ...
}

Notice: this is both a “snapshot” and a guard clause. This kind of code is almost always easier for the compiler, faster for the program, and clearer for a human.

5. Cheat sheet: where smart cast works and where it doesn’t

Sometimes it’s useful not to “memorize rules,” but to keep one thought in mind: smart cast is possible when the value is stable. But to make it easier to check, here’s a compact table.

Situation in code Why the compiler may refuse What to rewrite
Checked if (x != null) and then use x Usually ok if x is a local val or a parameter Nothing — this is the “ideal” case
var x: T? checked and then used later var may change, especially if it’s read twice Make a val snapshot = x and work with snapshot
Checked obj.prop != null, and then use obj.prop.something prop is read twice; the getter may return something else val p = obj.prop and then use p
Use inside a lambda after a check The lambda may run later or in another context Snapshot before the lambda, or ?.let { ... }
Complex code where after the check there is an assignment to the same variable The check may have become outdated Guard clause or a snapshot before changes

And yes: in Kotlin 2.x some scenarios started to “pass” better than before (especially in combination with inline functions and certain conditions), but you still win if you write code in the style “one read → check → use.”

6. Typical mistakes

Mistake #1: “I checked user.name != null, so user.name.length is definitely safe.”
This is one of the most common traps, because visually it seems obvious. But accessing a property is not the same as accessing a local variable: it can be a getter, a repeated read, a state change. The fix is almost always simple: val name = user.name, and then work with name.

Mistake #2: “I’ll just slap on !! real quick because I’m sure.”
!! is not a solution to smart-cast; it’s just a way to ask the program to crash later and louder. If you’re sure that null is impossible here by contract, it’s better to use requireNotNull/checkNotNull with a clear message. If null is possible, then you must handle that scenario (early return, ?:, ?.let { ... }), rather than turning it into a landmine in the code.

Mistake #3: repeated computations instead of saving the result.
When you write if (find(...) != null) println(find(...).title), you are relying not only on smart cast, but also on the search function being deterministic and the state not changing. Even if that’s true right now, the code becomes less readable and can turn into a source of bugs during refactoring. The right style: compute once → put into a val found → then work with it.

Mistake #4: “I made a snapshot, but then I still use the original variable anyway.”
Sometimes a developer writes val snapshot = x, checks snapshot != null, and then inside the block accesses x again. That brings you back to the original problem: x is still nullable and potentially mutable. If you took a snapshot, use it consistently.

Mistake #5: turning ?.let { ... } into a nesting doll of let inside let.
let is great, but if you start building a “pyramid” of three nested let calls, the code becomes heavy again. In such cases it’s better to create a few clear intermediate vals, use guard clauses, or carefully split the code into a couple of functions where each works with already non-null values.

1
Task
Kotlin SELF,  41 level,  2 lecture
Locked
Message status
Message status
1
Task
Kotlin SELF,  41 level,  2 lecture
Locked
Attendee badge
Attendee badge
1
Task
Kotlin SELF,  41 level,  2 lecture
Locked
Report label
Report label
1
Task
Kotlin SELF,  41 level,  2 lecture
Locked
Header key
Header key
Комментарии
  • популярные
  • новые
  • старые
Для того, чтобы оставить комментарий Вы должны sign in
This page has no comments yet