skip to content

Why can a `val` property defined in another module fail to smart-cast even when it looks like a plain field-backed property in your IDE?

level: seniorimportance: should knowfreq 35%

answer

  1. Modules compile separately => conservative by default
  2. Dependency could change field -> getter in a binary-compatible release
  3. Other-module property = non-stable by rule
  4. Same five non-stable cases; fix = local val capture

basics

~10 s

Code in another module is compiled separately. The compiler treats its properties conservatively because the implementation could change or use a getter it can't fully trust, so it won't risk a smart cast.

solid answer

~40 s

Kotlin's smart-cast stability check is scoped by **compilation module**. For a property in *your* module, the compiler can see whether it is a simple field-backed `val` with no custom getter and grant the smart cast. For a `val` from *another* module, it deliberately does not, because that module is compiled independently and its property could be (or later become) backed by a custom getter without your module recompiling against the change. Treating it as stable would be unsound across separate compilation. So properties from other modules are non-stable by rule, even if their current source looks like a plain field. The fix is identical: copy into a local `val` before the check. This is the rationale behind the `getValue`/binary-stability conservatism and is why the compiler error mentions a property 'declared in a different module'.

code

kotlin · 7 lines
kotlin
// :app depends on :lib
fun handle(c: LibConfig) {
    val host = c.host        // capture across the module boundary
    if (host != null) {
        connect(host)        // smart cast on the local val, not c.host
    }
}

go deeper

for a junior

Knows that some properties just won't smart-cast and that capturing into a local val fixes it.

for a middle

Identifies cross-module as one of the non-stable cases and applies the workaround consistently.

for a senior

Explains separate compilation and binary compatibility as the reason the compiler must stay conservative across modules.

for a principal

Reasons about ABI evolution guarantees and how language soundness must hold under independent module versioning and recompilation.

## Modules are compiled separately A **module** in Kotlin is a set of files compiled together (a Gradle source set, a Maven module, an IntelliJ module, etc.). Different modules are compiled in separate invocations and may evolve independently — you can swap a dependency's JAR without recompiling your own code. ## Why that breaks stability Smart casting on a property requires proving two reads return the same value. Within your module the compiler sees the property's real shape (field vs custom getter). Across a module boundary it deliberately does **not** rely on that, because: - The dependency could already use a custom getter you don't see in decompiled form. - Even if today it's a plain field, the dependency author could change it to a getter in a future binary-compatible release. Your already-compiled code must remain correct, so the compiler must not have assumed stability. Allowing the cast would make null-safety depend on an implementation detail of another module that can change underneath you — unsound. ```kotlin // in module :lib class Config { val endpoint: String? = "https://..." } // in module :app, depending on :lib fun use(c: Config) { if (c.endpoint != null) { // Smart cast may be refused: c.endpoint is from another module println(c.endpoint.length) } } ``` ## The unifying principle All smart-cast limitations share one root: **the read must be provably stable**. The non-stable cases are: - `var` (reassignable), - properties with a **custom getter**, - `open`/`abstract` properties (overridable getter), - **delegated** properties (`by` → `getValue` call), - properties declared in **another module** (separate compilation). ## The fix is always the same Capture once into a local `val`, which is unconditionally stable: ```kotlin fun use(c: Config) { val e = c.endpoint if (e != null) println(e.length) // smart cast on the local val } ``` Or use `?.let`, `?: return`, or `requireNotNull(c.endpoint)`. ## Terms - **Module / compilation unit** — code compiled together; cross-module references cross a binary boundary. - **Binary compatibility** — a dependency can change implementation (field → getter) without breaking callers' ABI, which is exactly why the compiler stays conservative. - **Stable value** — one the compiler can prove is unchanged between check and use; only local `val`s and same-module field-backed `final val`s qualify in general.

  • If the property were in the same module and field-backed, would it smart-cast?
    Yes. Within one module the compiler sees it's a plain `final val` with no custom getter and can prove stability, so the smart cast is granted.
  • How does binary compatibility justify the conservatism?
    A dependency can change a field-backed `val` into one with a custom getter while keeping the same public ABI. Your already-compiled code must stay correct, so the compiler could never have assumed the read was stable.

Trusting another module's property to stay stable is like trusting a contractor's blueprint you never get to re-inspect — they could rebuild it after you sign off.

saying these in an interview costs you the question

  • Assuming cross-module field-backed vals smart-cast like local ones
  • Not connecting the limit to separate compilation / ABI stability
  • Believing decompiled 'looks like a field' guarantees stability
  • Treating it as an IDE bug rather than a deliberate soundness rule

context