Kotlin/JS compiles Kotlin straight to actual JavaScript source, so anything crossing the boundary has to reconcile two genuinely different type systems — one statically typed and null-aware, one dynamic and permissive about almost everything.
Kotlin/JS is one of two ways Kotlin reaches the browser. Everything below — external, dynamic, @JsExport — is specific to the Kotlin/JS target; Kotlin/Wasm's JS interop looks similar in shape but works differently underneath, covered briefly at the end.
Calling JavaScript from Kotlin
Nothing in the JS ecosystem is visible to Kotlin by default — every function, object, or class you want to call has to be declared first, using the external modifier:
external fun alert(message: String)
fun showMessage(message: String) {
alert(message) // calls the real, browser-provided alert()
}external tells the compiler "this exists at runtime, take my word for the signature" — no implementation is generated, and the compiler doesn't check that a matching alert() actually exists in whatever environment the code eventually runs in.
For an entire npm package rather than a single global function, @JsModule imports it, typically paired with dynamic when the package's shape isn't worth declaring in full:
@JsModule("some-date-library")
external val dateLib: dynamic
fun formatToday(): String = dateLib.format(Date(), "YYYY-MM-DD")dynamic is Kotlin's escape hatch for untyped JS interop: any property or method access on a dynamic value compiles without a check, and a typo or wrong argument only shows up at runtime. It trades away Kotlin's type safety, so reach for a proper external interface — with real property and method declarations — over dynamic whenever the library's API is worth describing precisely; dynamic is for the cases where it genuinely isn't.
Calling Kotlin from JavaScript
Going the other way, @JsExport marks a top-level function, property, or class as visible to JavaScript. The compiler generates both the JS output and a matching TypeScript declaration file, so TypeScript consumers get real autocomplete and type-checking against your Kotlin API:
@JsExport
fun multiplyNumbers(a: Int, b: Int): Int = a * b
@JsExport
class Greeter(val greeting: String) {
fun sayHello() = println(greeting)
}import { multiplyNumbers, Greeter } from "./my-module.mjs";
console.log(multiplyNumbers(2, 4)); // 8
new Greeter("Hello from Kotlin!").sayHello();@JsExport only accepts types it can represent cleanly on the JS side — primitives, strings, and other @JsExport-annotated types. A function that takes or returns an ordinary Kotlin class without that annotation won't export cleanly, which is usually the first thing to check when a Kotlin API doesn't show up the way you expect on the JS side.
Working with JavaScript's async model
Kotlin/JS runs on the same single-threaded event loop as any other JavaScript code, so there's no real multithreading to coordinate here — the interop work is bridging Promises with coroutines, not synchronizing threads. kotlinx.coroutines provides await() as a suspend extension on Promise<T>:
external fun fetchUser(id: String): Promise<JsUser>
suspend fun loadUser(id: String): JsUser = fetchUser(id).await()This lets you call a Promise-returning JS API and consume the result the same way you'd consume any other suspend function elsewhere in a shared, coroutine-based codebase.
Kotlin/Wasm's JS interop
Kotlin/Wasm — the other browser target — has a JS interop story that looks similar in shape (external declarations, @JsExport) but is stricter underneath. dynamic isn't supported there at all; untyped JS values are represented by JsAny instead, and every interop declaration — external, @JsExport, and inline JS snippets alike — is typed against it. A project targeting wasmJs instead of js can expect the same vocabulary, but anything leaning on dynamic needs rewriting.
Conclusion
Kotlin/JS interop comes down to two keywords doing the actual bridging: external — plus @JsModule and dynamic for untyped libraries — to call JavaScript from Kotlin, and @JsExport to make Kotlin callable from JavaScript, with a generated TypeScript declaration file along for the ride. The type system doesn't extend past that boundary in either direction, so dynamic and external both trade Kotlin's compile-time guarantees for runtime trust — which is what makes the type-mapping and Promise/coroutine bridging above worth getting right rather than guessing at. Kotlin/Wasm covers the same ground with a stricter, more fully typed version of the same idea.