How does the compiler use Nothing-returning expressions in control-flow / smart-cast analysis, and where can this break down?
answer
- Nothing path => unreachable => smart-cast survives
- Needs Nothing *type*, not just always-throwing
- Breaks on var / custom getter / cross-module / closures
- Fix: local val, ?.let, requireNotNull
- else -> error() keeps when exhaustive for typing
basics
~20 sWhen a branch ends in something that never returns, the compiler knows execution can't continue down that path, so afterward it can treat variables as non-null or narrowed. This breaks if the variable could change between the check and use.
solid answer
~50 sKotlin's flow analysis tracks reachability. An expression of type `Nothing` (`throw`, `return`, `error()`, a `Nothing`-returning helper) marks the rest of that path **dead**, so any condition that would otherwise have to hold is known on the surviving path — enabling smart-casts. Example: `val x = m[k] ?: return` narrows `x` to non-null afterward, because the alternative path doesn't continue. It breaks down where Kotlin can't prove stability: `var` properties that might be mutated, `open`/custom-getter properties (could return different values per access), properties from other modules, or values captured in closures/coroutines that could change between check and use. In those cases the smart-cast is refused and you must use a local `val`, `?.let { }`, or an explicit non-null assertion. With `Nothing`-returning custom functions, the smart-cast only flows if the compiler sees the `Nothing` return type — inlining isn't required, but the declared type is.
code
kotlin · 7 linesclass Box { var v: String? = null }
fun show(b: Box) {
// b.v is a mutable property -> no smart-cast even after a Nothing check
val local = b.v ?: error("v was null") // copy to stable local
println(local.length) // OK
}go deeper
Knows that after ?: return a value is non-null but can't explain the mechanism.
Explains reachability-driven smart-casts and gives a working example.
Enumerates stability conditions where smart-casts break (var, custom getter, cross-module, closures) and the idiomatic fixes.
Ties the Nothing return type to flow-graph reachability, discusses thread/coroutine stability concerns, and the contract-helper interplay.
## Reachability is the engine Kotlin's smart-cast machinery is built on **control-flow reachability**. The compiler walks each path and, when it hits a `Nothing`-typed expression, marks the continuation as **unreachable**. Anything that had to be true to *avoid* the never-returning branch is then known on the path that survives. ```kotlin fun firstLen(xs: List<String>?): Int { val list = xs ?: return 0 // Nothing-typed 'return' on the null path return list.first().length // list : List<String> (non-null) } ``` The same works with `throw`, `error(...)`, `TODO()`, `continue`/`break` inside loops, and custom `Nothing`-returning helpers: ```kotlin fun fail(): Nothing = throw IllegalStateException() fun use(s: String?) { if (s == null) fail() println(s.length) // s smart-cast to String } ``` ## Why the declared Nothing type matters The compiler keys off the **declared/inferred return type** being `Nothing`. If `fail()` were declared `: Unit` (even though it always throws), no smart-cast would flow, because the type system would think control can continue. ## Where smart-casts break down Smart-casts require the value to be **stable** between the check and the use: - **`var` local or property** that could be reassigned -> refused if it might change. - **Mutable property of another class** (`val` in another module, or `open` val) — its getter could return different values on each access. - **Custom getters** — `val x get() = ...` recomputes, so the compiler won't smart-cast it. - **Delegated properties** (`by lazy`, `by Delegates.observable`) — accessed through a getter, not smart-castable. - **Captured variables** mutated in a lambda/closure, or values that another coroutine could change. ```kotlin class Box { var v: String? = null } fun show(b: Box) { if (b.v == null) error("null") // println(b.v.length) // ERROR: b.v is a mutable property, no smart-cast val local = b.v ?: error("null") println(local.length) // OK: local val is stable } ``` ## Workarounds - Copy to a **local `val`** first, then check/use. - Use **`?.let { }`** to capture the value in a stable parameter. - Use **`requireNotNull` / `checkNotNull`** (contract-backed) to both narrow and produce a stable local. - As a last resort, the **`!!`** non-null assertion (throws `NullPointerException` if null) — but that defeats the safety the type system offers. ## Subtlety: exhaustiveness and Nothing An `when` whose only fall-through branch is `else -> error(...)` is effectively exhaustive *for typing*: the result type is the LUB of the real branches, and the `else` contributes `Nothing`, which drops out.
- Why doesn't a smart-cast survive on `b.v` when `b.v` is a `var`?Between the null-check and the use, another thread or the same code could reassign b.v to null, so the compiler can't prove stability and refuses the smart-cast. Copy to a local val instead.
- If a function always throws but is declared `: Unit`, will callers get smart-casts after it?No. Flow analysis keys off the Nothing return type; with `: Unit` the compiler assumes control continues, so no narrowing happens. Declare it `: Nothing`.
A Nothing branch is a sealed-off corridor: once the compiler bricks it up, it knows everyone walking past must be on the open hallway, so it can assume things about them.
saying these in an interview costs you the question
- Claiming smart-casts work on any var property
- Saying an always-throwing function enables smart-casts regardless of its declared type
- Not knowing custom getters block smart-casts
- Suggesting !! as the first/idiomatic fix
- Believing reachability has nothing to do with smart-casts