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?
answer
- Modules compile separately => conservative by default
- Dependency could change field -> getter in a binary-compatible release
- Other-module property = non-stable by rule
- Same five non-stable cases; fix = local val capture
basics
~10 sCode 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 sKotlin'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// :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
Knows that some properties just won't smart-cast and that capturing into a local val fixes it.
Identifies cross-module as one of the non-stable cases and applies the workaround consistently.
Explains separate compilation and binary compatibility as the reason the compiler must stay conservative across modules.
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