skip to content

How does JavaScript interop in Kotlin/Wasm (wasmJs) differ from Kotlin/JS, and what are the rules for external declarations?

level: middleimportance: must knowfreq 45%

answer

  1. Wasm = real FFI boundary; JS = same language
  2. JsAny family: JsString/JsNumber/JsBoolean/JsReference
  3. No dynamic in Kotlin/Wasm
  4. external declarations map to JS, no body
  5. js("...") needs a constant string

basics

~20 s

Both let Kotlin call JavaScript, but Kotlin/Wasm runs as a real Wasm module, so values crossing the boundary are limited and use a special JsAny type family. Kotlin/JS compiles to JavaScript directly, so its interop is looser and supports dynamic.

solid answer

~40 s

In **Kotlin/JS** the output *is* JavaScript, so interop is loose: you can use the `dynamic` type and pass most Kotlin objects across freely. In **Kotlin/Wasm (wasmJs)** the output is a Wasm module living beside the JS host, so the boundary is stricter and typed. JS references are represented by the **`JsAny`** type hierarchy (`JsString`, `JsNumber`, `JsBoolean`, `JsArray`, `JsReference<T>`), and there is **no `dynamic`**. You declare JS APIs with `external` (interfaces, classes, functions, properties), and you can inline small JS snippets with the **`js("...")`** function whose body must be a constant string expression. Conversions use helpers like `.toJsString()` / `String.toString()` on `JsString`, and `external` function parameters/returns must be Wasm-interop-compatible types. Functions passed to JS must be wrapped appropriately. This stricter, statically typed boundary is the main interop difference.

code

kotlin · 10 lines
kotlin
external interface JsConsole : JsAny {
    fun log(msg: JsString)
}
external val console: JsConsole

fun greet(name: String) {
    console.log("Hello, $name".toJsString())
}

fun now(): Double = js("Date.now()")

go deeper

for a junior

Knows both can call JS but that Kotlin/Wasm is a separate module with a stricter boundary.

for a middle

Can declare external APIs, use JsString/JsAny conversions, and use js("..."), and explains the absence of dynamic.

for a senior

Explains the typed FFI rationale, JsReference for object handles, lambda adapters, and migration pitfalls from Kotlin/JS.

for a principal

Reasons about interop performance, boundary-crossing cost, and architecting code to minimize Wasm↔JS traffic in a real app.

## Why the boundary differs - **Kotlin/JS** compiles Kotlin **into JavaScript**. There is no real boundary — your Kotlin *is* JS at the end — so it offers the loose **`dynamic`** type (turns off type checking) and passes objects freely. - **Kotlin/Wasm (`wasmJs`)** compiles to a **WebAssembly module** that runs *next to* a JavaScript host. Crossing from Wasm to JS is a genuine FFI boundary, so it must be **typed and restricted**. ## The JsAny type family In `wasmJs`, JavaScript values are modeled by an external type hierarchy rooted at **`JsAny`**: - `JsString`, `JsNumber`, `JsBoolean` — JS primitives. - `JsArray<T>` — JS arrays. - `JsReference<T>` — an opaque handle wrapping a Kotlin object so it can be held by JS and brought back. There is **no `dynamic`** in Kotlin/Wasm. You must convert explicitly, e.g. `"hi".toJsString()` produces a `JsString`, and a `JsString` converts back with `.toString()`. ## external declarations You describe JS APIs with the **`external`** keyword: ```kotlin external interface Console : JsAny { fun log(message: JsString) } external val console: Console external fun encodeURIComponent(value: JsString): JsString ``` Rules to remember: - `external` members have **no body** — they map to JS. - Parameter and return types must be **Wasm-interop-compatible**: `JsAny` subtypes, primitives that map directly (e.g. `Int`, `Double`, `Boolean`), `String` (auto-converted), or function types wrapped for JS. - `external interface`s typically extend `JsAny`. ## The js("...") intrinsic For small inline JS you use the **`js("...")`** function. Its argument must be a **compile-time constant string**: ```kotlin fun alertHi() { js("alert('hi')") } fun nowMillis(): Double = js("Date.now()") ``` The compiler emits the snippet into the JS glue. You cannot build the string dynamically. ## Passing Kotlin lambdas to JS Function types crossing to JS must be interop-compatible; the compiler generates the adapter so a Kotlin lambda can be invoked from JS. You typically expose them through `external` function parameters typed as function types of JsAny-compatible types. ## Summary of differences vs Kotlin/JS - **No `dynamic`** in Wasm; everything is statically typed via `JsAny`. - **Explicit conversions** (`toJsString()`, etc.) instead of implicit object sharing. - **`js("...")`** requires a constant string; Kotlin/JS allowed richer `js()`/`dynamic` usage. - The Wasm boundary is a real FFI, so only a constrained set of types may cross.

  • Why was dynamic removed in Kotlin/Wasm?
    Because Wasm is a typed binary module separate from JS; there is no untyped JS object graph to lean on, so the boundary must be statically typed via the JsAny hierarchy.
  • What is JsReference<T> used for?
    It wraps a Kotlin object as an opaque handle so JS can hold onto it and pass it back into Wasm later, where it can be unwrapped to the original Kotlin instance.

Kotlin/JS is two people speaking the same language; Kotlin/Wasm is two people who need a customs checkpoint (JsAny) and a phrasebook (explicit conversions) to exchange anything.

saying these in an interview costs you the question

  • Claiming you can use dynamic in Kotlin/Wasm
  • Passing arbitrary Kotlin objects across the boundary without JsReference/conversion
  • Building the js("...") string at runtime from variables
  • Assuming Kotlin/JS and Kotlin/Wasm interop are identical
  • Forgetting external interfaces should extend JsAny

context