Capítulo 35 de 36

Chapter 35: ESM/CJS Interop — Why It's Complicated

Core Idea

There was never a specification for how ES Modules and CommonJS should interoperate — the rules in use today emerged historically from transpiler authors' best guesses (Babel-era conventions), and Node.js's own native implementation diverged from those guesses in several specific, still-relevant ways. Understanding this history explains why esModuleInterop/allowSyntheticDefaultImports exist and why Node.js's real behavior still has sharp edges bundler-transpiled code doesn't.

Key Concepts

  • The original transpiler mapping (pre-Node-ESM era): early ESM-to-CJS transpilers mapped named exports directly to exports.name and a default export to exports.default — a clean one-to-one correspondence that also made import * as ns transpile to a plain require() call, since a namespace import's members would line up with exports's properties.
  • The gap this exposed: real, hand-written CommonJS modules very commonly do module.exports = someFunction — assigning something that isn't a namespace-like object at all. Under the direct mapping, import * as fn from "./cjsModule" would have to resolve to that function, which is illegal per the ECMAScript spec (a namespace import must always be a Module Namespace Object, never a callable). This was the core compliance problem transpiler authors had to solve without any specification to guide them.
  • TypeScript's first response: error on import * as x for any module whose type didn't look like a namespace object, forcing the older import x = require(...) syntax as the workaround — spec-compliant, but not migratable to real ESM without a rewrite, and (per a known quirk) inconsistently bypassable if the exported function happened to merge with an empty namespace declaration.
  • The convention other tools converged on: mark transpiler-generated CJS output with a special exports.__esModule = true flag. A default import then checks for that flag at the require boundary — if present, link to exports.default (true ESM-style default); if absent (a hand-written "real" CJS module), link to the entire exports object as if it were the default export. This __esModule-conditional behavior originated in Traceur, then Babel/SystemJS/Webpack.
  • TypeScript's allowSyntheticDefaultImports (1.8): let the type-checker treat a default import as linking to the whole exports for modules with no export default, matching what Babel/Webpack already did at runtime — but this flag only affected type-checking, not what tsc itself emitted, meaning it could silence a real error while still emitting commonjs-targeted code that would crash under Node.js. It also left a namespace-import vs. default-import typing inconsistency, since namespace-import typing wasn't updated to match.
  • esModuleInterop (2.7): the actual fix — updated both the type-checking rules to fully match what mainstream transpilers/bundlers did, and the commonjs emit itself to use the same __esModule-conditional logic (plus a helper ensuring import * always produces a proper object, stripping call signatures) — finally putting TypeScript's checking, TypeScript's emit, and the rest of the ecosystem in agreement.
  • Then Node.js shipped native ESM (v12) with its own, different rules — sharing the "CJS module gets a synthetic default export of its whole exports" idea, but diverging in several concrete, still-relevant ways:
    • No __esModule detection ("double default" problem): Node.js's ESM loader doesn't check for the __esModule marker at all — it always synthesizes a default export from a CJS module's exports. A transpiled module that itself set exports.__esModule = true and exports.default = fn therefore ends up with a "double default" under real Node ESM: import doSomething from "dependency"; doSomething() (works after bundler transpilation) vs. doSomething.default() (the form that actually works when Node's own ESM loader is the one doing the importing).
    • Unreliable named exports from CJS: Node.js does try to expose a CJS module's exports properties as ESM named imports, but via static syntactic analysis performed before any code runs — so a property assigned through a non-literal expression (exports["worl" + "d"] = ...) is invisible to Node's named-import synthesis even though it works fine as a property access off the default import, and even though the equivalent code works as a named import under bundler-style transpilation.
    • require couldn't load a true ES module at all before Node.js v22.12.0 — a real CJS module can require() a transpiled ESM-to-CJS module (since both are CJS at runtime), but historically crashed trying to require() a genuinely native ES module. This meant a published library could not migrate from transpiled output to real ESM without breaking any consumer that requires it — a major reason the ecosystem's "just ship ESM" migration took so long. (Node 22.12+ finally allows synchronous require of an ES module, with restrictions around top-level await.)

Code Examples

// A CJS dependency with a transpiler-style "__esModule" flag:
// exports.__esModule = true; exports.default = function doSomething() {};

import doSomething from "dependency";
doSomething();          // works after bundler transpilation
doSomething.default();  // the form that actually works under Node.js's native ESM loader
  • What it demonstrates: the "double default" mismatch — the same dependency behaving differently depending on whether the importer went through a transpiler or Node.js's real ESM loader.

Reference Tables

BehaviorBundler/transpiler conventionNode.js native ESM
CJS default importconditional on __esModule flagalways synthesizes default from whole exports
CJS named importsresolved at runtime, always accuratestatic syntactic analysis before execution — can miss dynamically-assigned properties
require() an ES modulen/a (both sides already CJS after transpiling)crashes before Node 22.12; synchronous require allowed from 22.12+ (with top-level-await restriction)

Key Takeaways

  1. Enable esModuleInterop for any project targeting commonjs/similar emit and interacting with real-world CJS packages — it's the setting that actually aligns TypeScript's emitted JS with the interop behavior most tooling assumes.
  2. Don't assume code that works when transpiled/bundled will behave identically under Node.js's native ESM loader — the __esModule-flag detection gap and static-analysis-based named exports are real, still-current divergences.
  3. module: node16/node18/nodenext exists specifically because it enforces Node's actual interop rules instead of the more permissive bundler-convention rules esModuleInterop alone provides — pick it for anything genuinely running under Node.js.

Connects To

  • Module Theory: the host-modeling framework this chapter's Node.js-specific quirks are examples of.
  • Module Syntax & the module Compiler Option: esModuleInterop's effect on commonjs/amd/umd emit specifically.
  • Modules: export default/import * as ns syntax this chapter's history concerns.