You are designing the public API of a Kotlin/JS library consumed by a large TypeScript app. How do you structure the @JsExport surface for a stable, idiomatic, well-typed .d.ts?
answer
- .d.ts = the published contract; guard it in CI
- Thin exported facade over rich internals
- Exportable types only; convert at the edge
- @JsName for names + overloads; avoid default args
- Renames/removals = breaking changes
basics
~20 sKeep a small, deliberate set of exported types using only JS-friendly types, convert rich Kotlin types at the edge, use @JsName for clean names, and treat the generated TypeScript file as a contract you don't break between versions.
solid answer
~50 sTreat the `@JsExport` surface as a **published contract**, not an afterthought. Principles: (1) Expose a **thin facade** — a few exported classes/functions/objects — over rich internal Kotlin; never leak non-exported types. (2) Use only **exportable types** at the boundary; convert collections to `Array`, `Long` to `String`/`Double`, and prefer small exported DTO classes over Kotlin data classes with non-exportable members. (3) Use **@JsName** to give JS-idiomatic names and to project overloads onto distinct names; avoid default arguments (they synthesize overloads). (4) Enable **TypeScript declaration generation** and **review the `.d.ts` in CI** as a golden file so accidental signature changes are caught. (5) Version the surface carefully: renames/removals are breaking changes for TS consumers. (6) Prefer plain value-shaped results and explicit error signaling that maps to JS, since Kotlin exceptions cross to JS as thrown `Error`s. The goal is a `.d.ts` that reads like a hand-written, idiomatic TypeScript API.
code
kotlin · 13 linesinternal data class Order(val id: Long, val lines: List<String>)
@JsExport
class OrderView(val id: String, val lineCount: Int)
@JsExport
class OrderApi(private val repo: Repo) {
@JsName("load")
fun load(id: String): OrderView {
val o = repo.find(id.toLong())
return OrderView(o.id.toString(), o.lines.size)
}
}go deeper
Knows to keep exported types simple and JS-friendly.
Builds a facade and converts collections/Long at the edge.
Adds @JsName policy, avoids default args, and reasons about exception/async mapping to JS.
Governs the .d.ts as a versioned contract (golden snapshot in CI, additive evolution) and sets org-wide export-surface design rules.
## Mindset: the .d.ts is a contract A Kotlin/JS library's real public API is the generated **TypeScript declaration file** (`.d.ts`). TypeScript consumers compile against it. So design it deliberately and guard it like any published API. ## 1. Thin facade over rich internals Keep your domain logic in normal Kotlin (sealed hierarchies, `Long`, `List`, coroutines). Expose a **small** set of `@JsExport` declarations that translate to/from JS-shaped types. ```kotlin // internal rich model — NOT exported internal data class Order(val id: Long, val lines: List<Line>) // exported facade @JsExport class OrderApi { fun load(id: String): OrderView = repo.find(id.toLong()).toView() } @JsExport class OrderView(val id: String, val total: Double, val lineCount: Int) ``` Never let a non-exported type appear in an exported signature — it breaks the `.d.ts`. ## 2. Exportable types only, converted at the edge - `List`/`Set`/`Map` → `Array` (`toTypedArray()`), or a small exported wrapper. - `Long` → `String` (exact) or `Double` (lossy). - Enums export, but consider exposing `String` constants if TS ergonomics matter. - Nullable maps to `T | null`. ## 3. Names and overloads - `@JsName` for JS-idiomatic names and to split overloads (JS has no overloading). - **Avoid default arguments** across the boundary — Kotlin synthesizes overloads for them, polluting the `.d.ts`. Use nullable params or an options class. ## 4. Generate and guard the .d.ts - Turn on TS declaration generation on the JS target. - Commit the generated `.d.ts` (or a snapshot) and **diff it in CI** as a golden file so any signature drift is reviewed intentionally. ```ts // golden snapshot reviewed in CI export class OrderApi { load(id: string): OrderView } export class OrderView { readonly id: string; readonly total: number; readonly lineCount: number } ``` ## 5. Versioning discipline Renaming an exported member or changing a signature is a **breaking change** for every TS consumer. Add, don't mutate; deprecate before removing. ## 6. Errors and async cross the boundary - Kotlin **exceptions** surface to JS as thrown `Error`s — document them or return result-shaped objects for predictability. - For async, expose JS-friendly results; `Promise`-returning APIs are cleaner for TS than leaking Kotlin coroutine machinery. (Coroutine↔Promise bridging detail belongs to broader JS-interop topics, but the API-design point stands: hand JS a `Promise`-like contract.) ## Outcome A `.d.ts` indistinguishable from a thoughtfully hand-written TypeScript API: small, fully typed, stable, and JS-idiomatic — with all Kotlin richness safely behind the facade.
- How do you prevent accidental breaking changes to the TypeScript surface?Generate the .d.ts, commit a golden snapshot, and fail CI on any unexpected diff so signature changes are reviewed and versioned deliberately.
- Why avoid Kotlin data classes directly at the export boundary?They often contain non-exportable members (Long, List) and generated members that don't map cleanly; small purpose-built exported DTOs give an honest, stable .d.ts.
Design the export surface like an embassy: a small, well-documented front desk (facade) handling foreign visitors, with all the messy internal bureaucracy kept out of sight.
saying these in an interview costs you the question
- Exporting the whole rich domain model directly
- Leaking non-exported types or Long/List into signatures
- Relying on default arguments across the boundary
- Renaming exported members casually without versioning
- Not reviewing the generated .d.ts as a contract