skip to content

A `val` property has a custom getter that may return different values on each access. Why does Kotlin refuse to smart-cast it even though it is declared `val`?

level: middleimportance: should knowfreq 55%

answer

  1. Custom getter = function call on every read
  2. Two reads may disagree => no smart cast
  3. Delegated (by) properties also blocked
  4. Independent of val/var; fix = read once into local val

basics

~10 s

A val with a custom getter is really a function call every time you read it. Two reads can return different values, so checking once does not guarantee the next read is still non-null.

solid answer

~40 s

Smart casting requires the compiler to prove that two reads of the same property yield the same value. A plain `val` backed by a field satisfies this. But a `val` with a **custom getter** runs arbitrary code on each access — it may return a fresh or different value every time. So `if (obj.prop != null) { obj.prop.length }` reads `prop` twice, and the second read could be null or a different object. The compiler conservatively refuses the smart cast. The same logic applies to delegated properties (`by`), whose `getValue` is a function call. The fix is to read once into a local `val`: `val p = obj.prop; if (p != null) { p.length }`. Note this is independent of mutability — a `val` getter is non-stable purely because it is computed, not stored.

code

kotlin · 8 lines
kotlin
class Cache {
    val token: String? get() = store["t"] // computed each read
}

fun run(c: Cache) {
    val t = c.token       // capture once
    if (t != null) println(t.length) // smart cast on t, not c.token
}

go deeper

for a junior

Recognizes that a property with a getter behaves like a method call and can apply the capture-into-local fix.

for a middle

Explains that two reads may differ so the cast is unsound, and knows delegation is affected the same way.

for a senior

Articulates that the compiler does not analyze getter bodies and treats any custom/open getter as non-stable by rule.

for a principal

Discusses the soundness tradeoff and why per-getter analysis is intentionally avoided for predictability and modular compilation.

## Stability requires identical reads A smart cast narrows a type only if the compiler can guarantee that the value checked is the *same* value used afterwards. For a property, that means two consecutive reads must return the same thing. ## Backing field vs custom getter A property declared `val x: String? = ...` with no custom accessor is backed by a **field** — reading it just loads that field, and (for a `val`) the field never changes. Stable. A property with a **custom getter** has no such guarantee: ```kotlin class Box { val value: String? get() = if (Random.nextBoolean()) "hi" else null } fun use(b: Box) { if (b.value != null) { // Smart cast NOT allowed: each read of b.value runs the getter again println(b.value.length) // compile error } } ``` Each access to `b.value` invokes `get()`, which is arbitrary code. The check and the use are two *separate* invocations that may disagree. The compiler error reads roughly: *"Smart cast to 'String' is impossible, because 'b.value' is a property that has open or custom getter."* ## Delegated properties too Properties using `by` (delegation) route reads through `getValue`, which is also a function call: ```kotlin val name: String? by lazy { compute() } ``` The compiler cannot prove two reads coincide, so no smart cast. ## This is orthogonal to `val` vs `var` The declaration keyword does not save you: a `val` with a getter is non-stable because it is *computed on each read*, not because it can be reassigned. Conversely, a `private val` field in the same module with no getter is stable. ## The fix: read once into a local `val` ```kotlin fun use(b: Box) { val v = b.value // single read, captured in a stable local val if (v != null) { println(v.length) // smart cast works on v } } ``` ## Why the compiler is strict Allowing the cast would be unsound: if the getter returns null on the second read, `.length` would NPE despite the null check passing. Kotlin's null-safety guarantee depends on this strictness. ## Terms - **Backing field** — the hidden storage (`field`) behind a simple property. - **Custom getter** — user-supplied `get()` that computes the value on each read. - **Delegated property** (`by`) — reads/writes forwarded to a delegate's `getValue`/`setValue`.

  • Does this also affect delegated properties like `by lazy`?
    Yes. Delegation routes reads through `getValue`, a function call, so the compiler cannot prove two reads match. Even `by lazy` (which memoizes) is blocked because the compiler reasons about the general delegate contract, not the specific delegate.
  • If I make the getter return a stored field unconditionally, does smart cast return?
    No. The presence of a custom getter at all blocks the smart cast; the compiler does not analyze the getter body to decide stability.

Asking a custom getter twice is like asking a moody oracle twice — same question, possibly different answer.

saying these in an interview costs you the question

  • Believing `val` alone guarantees smart-castability
  • Not realizing a custom getter runs on every read
  • Thinking the compiler inspects the getter body to allow the cast
  • Forgetting delegated (`by`) properties are also affected

context