In a Kotlin Multiplatform project, why can't `dynamic` (or most `external` declarations) live in common code, and what failure modes does misusing `external`/`dynamic` introduce?
answer
- dynamic is JS-only → illegal in commonMain
- Interop lives in jsMain, exposed via expect/actual
- Errors become TypeError/ReferenceError at runtime
- Facade drift + infectious dynamic + lost null-safety
- Narrow with unsafeCast, model optionals as nullable, test the seam
basics
~20 sdynamic only exists on the JS target, so it can't appear in shared common code that also compiles to JVM or Native. And because external/dynamic bypass real implementation checks, mistakes turn into runtime JavaScript errors instead of compile errors.
solid answer
~50 sCommon code in KMP must compile to every target (JVM, Native, JS, Wasm), so it can only use constructs valid everywhere. `dynamic` is a JS-only type, so it's illegal in `commonMain` — it belongs in the `jsMain` source set. Most hand-written `external` facades are likewise JS-specific and stay in `jsMain` (commonly exposed to common code through `expect`/`actual`). The failure modes: (1) any error in an `external` signature or a `dynamic` access compiles fine but fails at runtime as a `TypeError`/`ReferenceError`, with no compiler safety net; (2) `external` facades silently drift from the real JS API; (3) `dynamic` is infectious and erodes type-safety far from its origin if not narrowed; (4) null-safety is lost — JS `null`/`undefined` can flow into non-null Kotlin types. Mitigation: keep interop in `jsMain`, expose typed `expect`/`actual` boundaries, narrow `dynamic` with `unsafeCast`, and test the interop at runtime.
code
kotlin · 12 lines// commonMain — typed, target-agnostic
expect class Storage {
fun get(key: String): String?
}
// jsMain — interop hidden behind the actual
external val localStorage: dynamic
actual class Storage {
actual fun get(key: String): String? =
localStorage.getItem(key).unsafeCast<String?>() // undefined modeled as null-ish
}go deeper
Knows dynamic is JS-only and errors can appear at runtime.
Explains source-set placement (jsMain) and that wrong signatures fail at runtime.
Uses expect/actual to expose typed APIs, narrows dynamic, and handles undefined-vs-null and facade drift.
Architects the interop seam for a multiplatform codebase, sets testing/null-modeling policy, and minimizes the untyped surface.
## Why common code is constrained A KMP module's `commonMain` source set is compiled **once per target** (JVM, Native, JS, Wasm). Therefore it may use only language features and APIs that exist on **all** of them. - **`dynamic` is JS-only.** There's no notion of an untyped, runtime-resolved member access on the JVM or Native. So `dynamic` in `commonMain` simply won't compile. It lives in **`jsMain`**. - **`external` is interop-target-specific.** Hand-written `external` facades describe JS symbols, so they belong in `jsMain` too. To surface their capabilities to shared code, you use the **`expect`/`actual`** mechanism: declare an `expect` typed API in common, and provide a JS `actual` that wraps the `external`/`dynamic` interop. ```kotlin // commonMain expect fun platformLog(message: String) // jsMain external val console: dynamic actual fun platformLog(message: String) { console.log(message) } ``` ## Failure modes of `external`/`dynamic` ### 1. Runtime-only errors Both bypass implementation checking. A wrong signature in an `external` fun, or a typo on a `dynamic` member, compiles cleanly and throws at runtime — a `TypeError` (member not a function) or `ReferenceError` (symbol missing). The compiler can't help. ### 2. Facade drift An `external` facade is a *claim* about JS shape. If the library updates or you mistype a parameter type, Kotlin happily trusts the stale claim. The mismatch is silent until that path runs. ### 3. Infectiousness of `dynamic` Results of operations on `dynamic` are `dynamic`. Without prompt narrowing (`unsafeCast<T>()` into a real type), untyped values seep deep into otherwise-typed code, so a failure can surface far from the boundary. ### 4. Lost null-safety JS has `null` *and* `undefined`. A `dynamic` (or a too-optimistic `external` non-null return) can deliver `undefined` into a Kotlin non-null type, defeating null-safety and producing confusing downstream NPE-like crashes. ## Mitigations - Keep all `external`/`dynamic` interop in **`jsMain`**; never in `commonMain`. - Expose interop to shared code through **`expect`/`actual`** with fully typed signatures. - **Narrow `dynamic` immediately** with `unsafeCast<T>()` into an `external interface` you control. - Model genuinely-optional JS values as **nullable** Kotlin types, not non-null. - Cover the interop boundary with **runtime tests** (a green compile proves nothing here). ## Takeaway `external`/`dynamic` move correctness from compile time to runtime and are JS-target-bound. Treat them as a guarded boundary in `jsMain`, expose only typed APIs to common code, and test the seam.
- How do you expose a JS-only `external` API to shared KMP code?Declare a typed `expect` API in `commonMain` and provide a JS `actual` in `jsMain` that wraps the `external`/`dynamic` interop.
- Why is JS `undefined` a specific hazard with `external`/`dynamic`?Kotlin only models `null`, not `undefined`; an `undefined` can flow into a non-null Kotlin type and crash later. Model such values as nullable and convert explicitly.
saying these in an interview costs you the question
- Putting `dynamic` in `commonMain` and expecting it to compile
- Assuming a clean compile means the interop is correct
- Ignoring the JS `undefined` vs Kotlin `null` distinction
- Letting `dynamic` propagate from `jsMain` into shared abstractions
- Not testing the interop boundary at runtime