skip to content

How do you make the Kotlin/JS IR target emit ES modules, and what does that change about the output and interop?

level: seniorimportance: should knowfreq 38%

answer

  1. useEsModules() in js(IR) block
  2. ESM = native import/export, statically analyzable
  3. Default historically UMD; CommonJS for Node
  4. ESM implies granular per-file output
  5. @JsExport -> ESM named exports

basics

~10 s

You tell the compiler to output ES modules instead of the default UMD format. Then the generated JavaScript uses import/export statements, so modern browsers and tools can load and tree-shake it natively.

solid answer

~40 s

The IR backend can emit several JS module kinds; ES modules (ESM) is the modern one using native import/export. You opt in via the Gradle DSL with useEsModules() inside the js(IR) target (it sets the module kind to ES and toggles related options), or per-binary with esModules. ESM output produces .mjs-style modules with real export statements, enabling browsers/bundlers to load and statically analyze them, and pairs well with per-file output (useEsModules implies granular files). It's also a prerequisite for some interop niceties: @JsExport surfaces become ESM named exports, and you can use top-level @JsModule with ESM imports. Contrast with the historical default UMD (works as AMD/CommonJS/global) and plain CommonJS for Node. Choosing ESM affects how consumers import your artifact and how TypeScript definitions line up.

code

kotlin · 13 lines
kotlin
kotlin {
    js(IR) {
        useEsModules()
        browser()
        binaries.executable()
        compilerOptions {
            // generateTypeScriptDefinitions() pairs well with ESM exports
        }
    }
}

@JsExport
fun greet(name: String): String = "hi \$name"

go deeper

for a junior

Knows ESM means import/export-style JavaScript output.

for a middle

Can enable useEsModules() and name UMD/CommonJS/ESM differences.

for a senior

Chooses module kind per consumer, understands granular output, and aligns @JsExport/.d.ts with ESM.

for a principal

Defines the org's interop/publishing strategy across consumers and reasons about ESM vs CommonJS migration risk in mixed toolchains.

## Module formats the IR backend can emit JavaScript has several module systems. Kotlin/JS can target: - **UMD** (Universal Module Definition) — the historical default; a wrapper that works as AMD, CommonJS, or a global. Maximally compatible, not statically analyzable. - **CommonJS** — Node's `require`/`module.exports`. - **AMD** — older async browser loader. - **ES modules (ESM)** — native `import`/`export`, the modern standard understood by browsers and modern bundlers. - **PLAIN** — globals. ## Why ESM ESM uses **static** `import`/`export`, so tools can analyze the dependency graph at build time (better bundler tree-shaking, native browser `<script type="module">` loading, cleaner npm publishing). It's the direction the ecosystem has moved. ## Enabling ESM in Gradle Use the Kotlin Gradle DSL: ```kotlin kotlin { js(IR) { useEsModules() // module kind = ES, per-file granular output browser() binaries.executable() } } ``` `useEsModules()` switches the module kind to ES and enables **granular (per-file) output** so each module is its own file with real `export` statements. You can also set the module kind directly via compiler options (`moduleKind = "es"`), but the DSL helper is the supported path. ## What changes in the output - Generated files use `export`/`import` rather than a UMD wrapper. - `@JsExport`ed Kotlin declarations appear as **ESM named exports**, so JS/TS consumers do `import { myFn } from './app.mjs'`. - Interop imports via `@JsModule("some-pkg")` / `@JsNonModule` resolve through the ESM import mechanism. - TypeScript `.d.ts` (from `generateTypeScriptDefinitions()`) aligns with the ESM export shape. ## Interop implications ```kotlin @JsModule("lodash") external fun chunk(array: Array<Int>, size: Int): Array<Array<Int>> ``` With ESM output this is consumed as a native module import. Picking the wrong module kind for your consumer (e.g. ESM artifact loaded by a CommonJS-only Node script without interop config) is a common integration bug. ## Choosing a format - Browser app / modern bundler / npm publishing -> **ESM**. - Legacy Node script expecting `require` -> **CommonJS**. - Maximum compatibility, don't care about static analysis -> **UMD**.

  • Why does useEsModules() also produce per-file (granular) output?
    ESM's value is static analyzability; emitting each module as its own file with explicit import/export lets bundlers and browsers resolve and tree-shake the graph natively, which a single UMD blob can't offer.
  • What's the risk of shipping an ESM artifact to a CommonJS-only Node consumer?
    It can't require() an ESM module directly; you'd hit import/require interop errors. You'd need CommonJS output or proper module interop configuration on the consumer side.

UMD is a universal power adapter that works everywhere but is bulky; ESM is the native wall socket — sleek and standard where the modern ecosystem already lives.

saying these in an interview costs you the question

  • Thinking the only output format is UMD
  • Believing ESM is enabled by default
  • Confusing module kind (UMD/ESM/CommonJS) with bundling/minification
  • Not knowing @JsExport drives the exported surface
  • Assuming ESM works unchanged in any Node script

context