skip to content

You need to call a third-party npm library from Kotlin/JS. When would you write `external` declarations versus using `dynamic`, and what are the tradeoffs?

level: middleimportance: should knowfreq 40%

answer

  1. external = typed facade, dynamic = escape hatch
  2. external: compile-time errors; dynamic: runtime errors
  3. @JsModule/@JsName bind external to npm exports
  4. Hybrid: external hot path, dynamic at the edge
  5. Narrow dynamic with unsafeCast<T>() ASAP

basics

~20 s

Use external when you can describe the library's shape — you get type checking, autocomplete, and null-safety. Use dynamic for quick or genuinely free-form access where typing isn't worth it, accepting that mistakes only surface at runtime.

solid answer

~40 s

Default to `external`: you write typed Kotlin facades (`external class`/`interface`/`fun`, usually with `@JsModule`/`@JsName` to bind the npm export) so the compiler verifies call sites, gives IDE help, and enforces null-safety. The cost is up-front authoring and keeping facades in sync with the library's API. Reach for `dynamic` when the API is sprawling, loosely typed, returns free-form JSON, or you're prototyping — it eliminates facade work but disables all checks, so typos become runtime `TypeError`s and there's no autocomplete. A pragmatic hybrid: write `external` declarations for the parts you use a lot, and confine `dynamic` to a narrow boundary, narrowing back to typed values with `unsafeCast<T>()` as soon as you trust the shape. Treat broad `dynamic` as tech debt to be replaced by facades.

code

kotlin · 11 lines
kotlin
// Typed facade for the part you use often
@JsModule("date-fns")
external object dateFns {
    fun format(date: Date, pattern: String): String
}

// dynamic only at a narrow boundary, then narrowed
fun readConfig(raw: dynamic): Config =
    raw.unsafeCast<Config>()

external interface Config { val retries: Int }

go deeper

for a junior

Knows external is typed and dynamic is untyped, and prefers the typed option.

for a middle

Explains @JsModule/@JsName binding, the effort-vs-safety tradeoff, and runtime-vs-compile failure modes.

for a senior

Designs the hybrid: external hot path, narrow dynamic boundary, unsafeCast narrowing, manages facade drift.

for a principal

Sets org policy and tooling for bindings, weighs maintenance cost of facades vs codegen, and treats broad dynamic as tracked debt.

## The decision Both are interop tools, but they sit at opposite ends of a safety spectrum: - **`external`** = you *declare the types*. The compiler keeps checking your calls. Maximum safety, some authoring cost. - **`dynamic`** = you *opt out of types*. Zero authoring cost, zero safety; errors surface only at runtime in JS. ## When `external` wins - The library has a stable, knowable API you call repeatedly. - You want IDE autocomplete, refactoring support, and null-safety. - You're building a shared binding others on the team will reuse. ```kotlin @JsModule("lodash") external object lodash { fun chunk(array: Array<Int>, size: Int): Array<Array<Int>> } ``` The compiler now checks every `lodash.chunk(...)` call. (`@JsModule` ties the declaration to the npm module; `@JsName` maps differing names.) ## When `dynamic` wins - The data is genuinely free-form (arbitrary JSON, a config bag). - The API is huge and you touch a tiny, shifting slice — facades aren't worth it. - You're prototyping and want to move fast. ```kotlin val result: dynamic = someLib.doWhatever() console.log(result.some.nested.field) // no checks ``` ## Tradeoffs table (in prose) - **Safety:** `external` full; `dynamic` none. - **Authoring effort:** `external` high; `dynamic` near zero. - **IDE help / refactor:** `external` yes; `dynamic` no. - **Failure mode:** `external` at compile time; `dynamic` at runtime (`TypeError`). - **Drift risk:** `external` facade can lag the real API (silent mismatch); `dynamic` never lies but never helps. ## The idiomatic hybrid Write `external` for the hot path; allow `dynamic` only at the edge, then **narrow immediately**: ```kotlin external interface Config { val retries: Int } val cfg: Config = rawDynamic.unsafeCast<Config>() ``` `unsafeCast<T>()` reinterprets the value with no runtime check, leaving `dynamic` behind. Keep `dynamic` surfaces small and documented so a typo can't propagate deep into the app. Track widespread `dynamic` usage as debt and replace it with facades over time.

  • What annotation binds an `external` declaration to a specific npm module's default export?
    `@JsModule("<module-name>")` (paired with `@JsNonModule` if you also target non-module builds); `@JsName` maps a Kotlin name to a differing JS name.
  • How do you stop `dynamic` from spreading through your codebase?
    Confine it to a thin boundary and immediately convert to typed values (e.g. `unsafeCast<T>()` into an `external interface`), keeping the rest of the code typed.

saying these in an interview costs you the question

  • Reaching for `dynamic` everywhere to avoid writing facades
  • Not knowing `@JsModule`/`@JsName` exist for binding externals
  • Claiming `external` validates against the real library at runtime
  • Letting `dynamic` propagate instead of narrowing it
  • Ignoring facade drift as a silent-mismatch risk

context