Capítulo 35 de 36
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.
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.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.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.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.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.exports" idea, but diverging in several concrete, still-relevant ways:
__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).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.)// 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
| Behavior | Bundler/transpiler convention | Node.js native ESM |
|---|---|---|
| CJS default import | conditional on __esModule flag | always synthesizes default from whole exports |
| CJS named imports | resolved at runtime, always accurate | static syntactic analysis before execution — can miss dynamically-assigned properties |
require() an ES module | n/a (both sides already CJS after transpiling) | crashes before Node 22.12; synchronous require allowed from 22.12+ (with top-level-await restriction) |
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.__esModule-flag detection gap and static-analysis-based named exports are real, still-current divergences.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.module Compiler Option: esModuleInterop's effect on commonjs/amd/umd emit specifically.export default/import * as ns syntax this chapter's history concerns.