Why does `if (user.address != null) { user.address.city }` sometimes fail to smart-cast a nullable property, and how does `user.address?.let { it.city }` solve it?
answer
- smart-cast needs a stable val
- var / custom getter / open / other-module = no smart-cast
- ?.let reads once into immutable it
- capture into a local val is equivalent
- single snapshot defeats mid-stream mutation
basics
~20 sSmart-cast fails on mutable (var) or non-local properties because their value could change between the null check and use. Capturing the value once with ?.let { it ... } binds it to a stable local, so the compiler knows it stays non-null inside the block.
solid answer
~50 sKotlin only smart-casts a `null` check into a non-null type when the compiler can **prove the value can't change** between check and use. For an open/`var` property, a property with a custom getter, or a property from another module, the compiler can't guarantee stability — a getter could return a different value on the second read — so `user.address.city` after `if (user.address != null)` won't compile. The fix is to **capture the value into an immutable local**: `user.address?.let { it.city }`. The `?.let` reads `address` exactly once, passes that snapshot in as `it` (an immutable parameter, smart-cast to non-null), and the block can't observe a mid-stream mutation. Equivalent alternatives: `val a = user.address; if (a != null) a.city`, or `user.address?.city` directly. The scope-function form is idiomatic because it scopes the captured value tightly and chains with Elvis.
code
kotlin · 5 linesclass Session(var token: String?)
fun authHeader(s: Session): String? =
s.token?.let { "Bearer $it" } // reads token once; it is a stable non-null String
// vs. broken: if (s.token != null) "Bearer " + s.token // smart-cast impossible on a vargo deeper
Knows ?.let avoids the null-check error but may not explain why smart-cast was refused.
Names var/custom-getter as smart-cast blockers and uses ?.let or a local val to fix it.
Explains the read-once/stability requirement, lists all four blocker cases, and connects it to concurrency safety.
Reasons about the soundness guarantee the compiler is protecting and prescribes capture-once patterns for shared mutable state across the codebase.
## What smart-cast is A **smart cast** is the compiler automatically treating a value as a more specific (here, non-nullable) type after a check, so you don't write an explicit cast. `if (x != null) x.foo()` works when `x` is a stable `val` local. ## When smart-cast is REFUSED The compiler refuses to smart-cast when the value's stability isn't guaranteed, notably: - a **`var`** property (could be reassigned, possibly by another thread), - a property with a **custom getter** (each read can return a different value), - an **`open` `val`** (a subclass might override with a custom getter), - a property defined in **another module**. In these cases reading the property twice — once in the check, once at use — could yield different results, so non-null after the check is unsound. You get: *"Smart cast to 'X' is impossible, because '...' is a mutable property that could have been changed by this time."* ```kotlin class User(var address: Address?) fun bad(user: User) { if (user.address != null) { // compile error: address is a var, can't smart-cast println(user.address.city) } } ``` ## How ?.let fixes it ```kotlin fun good(user: User) { user.address?.let { addr -> println(addr.city) // addr is an immutable param, smart-cast to non-null Address } } ``` `?.` reads `user.address` **once**. If non-null, that single snapshot is passed into `let` as `addr` (an immutable lambda parameter). Inside the block nobody can mutate `addr`, so the compiler safely treats it as non-null `Address`. ## Equivalent idioms ```kotlin val a = user.address // capture into a val if (a != null) println(a.city) // now smart-cast works println(user.address?.city) // safe-call chain, no let needed for a single access ``` ## Why the scope function is preferred It **captures once**, **scopes** the non-null value to a tight block, and **chains** naturally with `?: default`. It also avoids polluting the surrounding scope with an extra `val`. ## Thread-safety nuance Even the `val a = ...` capture is what makes it safe under concurrency: you read the shared mutable field exactly once into a local, so a concurrent writer can't null it out between your check and use.
- Does smart-cast work on a local `val`? On a local `var`?On a local val yes. On a local var it works only if the compiler can prove it isn't reassigned between the check and the use (e.g. no closure capture and no reassignment in between).
- Is there a thread-safety benefit to ?.let beyond the compiler error?Yes — it reads the shared mutable property exactly once into a local, so a concurrent writer can't change it between your check and your use.
It's like quoting someone: if you might re-ask them and get a different answer (a var/getter), write down what they said once (capture into it) and rely on that note.
saying these in an interview costs you the question
- Claiming smart-cast always works after a null check
- Not knowing var/custom-getter/open/other-module break it
- Saying ?.let is just syntactic sugar with no semantic effect
- Missing the read-once / single-snapshot point
- Thinking the issue is about let's return value rather than capture