skip to content

Explain @JsExport and how Kotlin types are exposed to JavaScript/TypeScript from the IR backend. What are the limits?

level: seniorimportance: should knowfreq 32%

answer

  1. @JsExport = stable name + DCE root + JS-visible
  2. @file:JsExport exports whole file
  3. generateTypeScriptDefinitions() -> .d.ts
  4. suspend/Long/value classes don't export cleanly
  5. Thin facade over rich internal Kotlin

basics

~10 s

@JsExport marks Kotlin declarations so they keep readable names and become callable from JavaScript. The IR backend can also generate TypeScript type files for them, but only certain Kotlin types map cleanly.

solid answer

~40 s

By default the IR backend mangles names and tree-shakes aggressively, so Kotlin declarations aren't directly usable from JS. @JsExport (applied to a function, class, or whole file) opts a declaration into a stable, JS-visible export and makes it a DCE retention root. Combined with generateTypeScriptDefinitions() (the Gradle helper, formerly the experimental .d.ts generation), the compiler emits TypeScript .d.ts so JS/TS consumers get types. Only an 'exportable' subset maps: primitives, String, Boolean, exportable classes/interfaces, functions, Array, and a few collection-ish types. Non-exportable Kotlin features — suspend functions, inline/value classes in some positions, Long historically, sealed-hierarchy exhaustiveness, default-argument overloads — either don't export or surface awkwardly. Exporting widely also defeats DCE. The pragmatic pattern is a thin, deliberately small @JsExport facade over rich internal Kotlin.

code

kotlin · 10 lines
kotlin
// Expose a Promise instead of a non-exportable suspend function.
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.promise
import kotlin.js.Promise

@JsExport
fun loadGreeting(name: String): Promise<String> =
    GlobalScope.promise { buildGreeting(name) }   // suspend stays internal

private suspend fun buildGreeting(name: String): String = "hi \$name"

go deeper

for a junior

Knows @JsExport makes a declaration callable from JavaScript with a stable name.

for a middle

Explains the exportable subset, file-wide export, and that .d.ts comes from generateTypeScriptDefinitions().

for a senior

Designs a thin facade, handles suspend via Promise, and weighs the DCE cost of exporting.

for a principal

Defines the cross-language API contract and TypeScript publishing strategy, governing the export surface for stability and bundle budgets.

## The default: not JS-friendly The IR backend optimizes for Kotlin-to-Kotlin: it **mangles** names (to avoid collisions and enable optimization) and **tree-shakes** unreferenced code. So a random Kotlin `class Foo` is not reliably callable from hand-written JavaScript. ## @JsExport `@JsExport` opts a declaration **into** the JS-visible surface: - It keeps the **declared (unmangled) name**. - It becomes a **DCE retention root** (so it and its references survive — see the bundle-size tradeoff). - It can be applied to a single `fun`/`class`, or **file-wide** with `@file:JsExport`. ```kotlin @JsExport class Calculator { fun add(a: Int, b: Int): Int = a + b } ``` From JS (with ESM output): `import { Calculator } from './app.mjs'`. ## TypeScript definitions The IR backend can emit **`.d.ts`** declaration files describing the exported surface so TypeScript consumers get full typing. Enable it in Gradle with **`generateTypeScriptDefinitions()`** on the binary/target. It only describes **exported** declarations. ## The exportable subset (the limits) Not everything maps to JS/TS. Roughly **exportable**: primitive number types (mapped to JS `number`), `Boolean`, `String`, exported classes/interfaces, top-level functions, `Array`, function types. **Problematic / non-exportable**: - **`suspend` functions** — coroutines have no native JS equivalent; you typically expose a `Promise`-returning wrapper (e.g. via `kotlinx.coroutines` `promise { }`) instead. - **`Long`** — historically not a native JS number; needs care. - **inline/`value` classes** in exported signatures — restricted. - **default arguments**, **sealed exhaustiveness**, and some generics surface awkwardly or not at all. - Non-exported types appearing in an exported signature trigger compiler warnings/errors. ## The facade pattern Because broad exporting both (a) leaks awkward types and (b) defeats DCE, the idiomatic approach is a **thin, intentional facade**: keep rich Kotlin internal, and `@JsExport` only a small, JS-shaped API (primitives, Promises, plain data) at the boundary. ```kotlin @JsExport fun fetchUserName(id: Int): Promise<String> = GlobalScope.promise { repository.loadName(id) } // suspend hidden behind Promise ``` ## Consuming JS the other way The reverse direction uses `external` declarations plus `@JsModule`/`@JsNonModule` to type third-party JS/npm packages for Kotlin — separate from `@JsExport`, which is about exposing Kotlin outward.

  • How do you expose a suspend function to JavaScript?
    You can't export it directly. Wrap it in a Promise-returning function — e.g. kotlinx.coroutines' promise { } builder — and @JsExport that wrapper instead.
  • Why is exporting an entire file with @file:JsExport often a bad idea?
    Every exported declaration becomes a DCE root, so tree-shaking can't trim them; it bloats the bundle and may surface non-exportable types as errors. Prefer a small deliberate facade.

@JsExport is the shop window: you deliberately display a few clean products for outside buyers, while the messy warehouse of Kotlin internals stays in the back.

saying these in an interview costs you the question

  • Thinking any Kotlin class is callable from JS without @JsExport
  • Trying to export a suspend function directly
  • Not knowing .d.ts comes from generateTypeScriptDefinitions()
  • Exporting the whole world and then complaining about bundle size
  • Confusing @JsExport (Kotlin->JS) with @JsModule/external (JS->Kotlin)

context