notes/node/cjs-esm-interop.md
How Node bridges CommonJS and ES modules.
When ESM imports CJS, Node synthesizes an ESM namespace from static CJS source analysis before evaluating the CJS module.
cjsPreparseModuleExports before building the wrapper (translators.js#L212).translators.js#L393-L418).kIsCachedByESMLoader (translators.js#L368); the CJS loader uses that marker during circular loads (loader.js#L1297-L1308).'module.exports' namespace key (v23.0.0 translators.js#L187).The lexer implementation changed without changing the supported grammar:
cjs-module-lexer (grammar).internalBinding('cjs_lexer') through nodejs/node#61456, backed by Merve (grammar).Node's synthetic default export for a CommonJS module is always the complete module.exports value. The translator skips a detected CommonJS property named default and unconditionally assigns the synthetic default and module.exports namespace exports to the complete value (v26.7.0 implementation, contract).
__esModule does not affect this direction of interop. For a CommonJS module that assigns a callable to exports.default, a native ESM default import is the containing object and the callable remains at .default. Node locks that result for canonical Babel and TypeScript output (fixtures, assertions).
| Node behavior | PR | Verified releases |
|---|---|---|
The ESM load hook can return source for format === 'commonjs'. | nodejs/node#50825 | v20.11.0, v21.3.0 |
| CJS source returned by a load hook can be preparsed into a namespace. | nodejs/node#50825, #54769 | [20.11.0, 21.0.0) and >=21.3.0 |
The load hook receives import-attributes context and reads CJS source at the boundary (v20.11.0 load.js#L113-L145). The translator then preparses CJS before namespace creation and evaluates via CJSModule._load (v20.11.0 translators.js#L190-L203).
| Node behavior | Evidence | Verified releases or window |
|---|---|---|
CJS require() can load eligible ESM instead of throwing ERR_REQUIRE_ESM. | nodejs/node#55085 | v20.19.0, v22.12.0, v23.0.0 |
Without require(esm), CJS require() rejects ESM with ERR_REQUIRE_ESM and recommends import(). | v18.20.8 docs, loader | v18.20.8 |
An ESM graph containing top-level await throws ERR_REQUIRE_ASYNC_MODULE; callers must use import(). | v20.20.2 docs, v24.15.0 loader | v20.20.2, v24.15.0 |
Normal require(esm) stops printing the experimental warning. | nodejs/node#56194 | v20.19.0, v22.13.0, v23.5.0 |
Extensionless .mjs lookup was broken. | nodejs/node#55085, #55590 | [20.19.0, 20.19.5), [22.12.0, 22.14.0) |
Synthetic CJS namespaces expose 'module.exports'. | nodejs/node#53848 | v23.0.0 |
Node checks the async graph before synchronous evaluation unless --experimental-print-required-tla is enabled (v24.15.0). process.features.require_module reports whether the runtime has require(esm) enabled (v24.15.0 docs, implementation).