Back to Tsx

CJS/ESM interop

notes/node/cjs-esm-interop.md

4.23.19.9 KB
Original Source

CJS/ESM interop

How Node bridges CommonJS and ES modules, and how tsx shapes transformed TypeScript so it still fits Node's CJS/ESM contracts.

Shared foundation: CJS preparse + synthetic namespaces

When ESM imports CJS, Node synthesizes an ESM namespace from static CJS source analysis. It does not discover named exports by running the CJS module first.

The parser implementation changed but the grammar contract stayed the same:

tsx depends on the grammar, not the implementation. Grammar changes are what to re-check when verifying named-export interop on a new Node line.

ESM importing CJS

Two gates control this direction:

GateNode changeNode PR(s)Verified releasestsx use
esmLoadReadFileThe ESM load hook can return source for format === 'commonjs'.nodejs/node#50825v20.11.0, v21.3.0src/esm/hook/load.ts can transform CJS before Node evaluates/preparses it.
cjsNamespaceFromLoadHookCJS source returned by the load hook can be preparsed into a namespace.nodejs/node#50825, #54769[20.11.0, 21.0.0) and >=21.3.0tsx can preserve named exports from transformed CommonJS TypeScript.

Node's load hook passes import attributes context and reads CJS source at the boundary (v20.11.0 load.js#L113-L124, load.js#L145; v21.3.0 load.js#L145). v20.11.0's translator then preparses CJS before namespace creation (translators.js#L190) and evaluates via CJSModule._load (translators.js#L203).

tsx uses this by transforming TypeScript to JavaScript before Node preparses it. The important emitted shape is esbuild's dead-code CJS export annotation:

text
0 && (module.exports = { namedExport });

The annotation never runs, but Node's CJS lexer recognizes it. parentImportsCommonJsExports detects when a parent imports named or namespace exports from a CJS target; resolve.ts adds tsx-commonjs-export-preparse=1; load.ts sees that query and returns transformed JavaScript so Node's preparse step can build the right namespace. The query is stripped from user-visible URLs after it has coordinated resolve/load/cache behavior.

CJS requiring ESM

Node's require(esm) support is a set of overlapping windows:

GateNode behaviorNode PR(s)Verified releases / windowtsx use
requireEsmCJS require() can load eligible ESM instead of throwing ERR_REQUIRE_ESM. Before: .mjs throws (v20.18.0 loader.js#L1285); after: loadESMFromCJS handles it (v20.19.0 loader.js#L1310, loader.js#L1509-L1511).nodejs/node#55085v20.19.0, v22.12.0, v23.0.0Enables native-style require(esm) interop in transformed TS.
requireEsmNoWarningNormal require(esm) stops printing the experimental warning. v22.12 emits it (loader.js#L1404); v22.13 keeps it behind tracing (loader.js#L1401-L1405).nodejs/node#56194v20.19.0, v22.13.0, v23.5.0Version-sensitive warning expectations.
requireEsmExtensionlessMjsBug window for extensionless specifiers resolving to .mjs. Broken window filters .mjs from extensionless lookup (v20.19.0 loader.js#L440, loader.js#L654-L658; v22.12.0 loader.js#L676-L680). Fixed by passing resolved format/source to loadESMFromCJS (v20.19.5 loader.js#L1303, loader.js#L1503; v22.14.0 loader.js#L1325, loader.js#L1536).nodejs/node#55085, #55590[20.19.0, 20.19.5), [22.12.0, 22.14.0)isFeatureSupportedInRange gate for the broken window only.
cjsNamespaceModuleExportsSynthetic CJS namespaces expose 'module.exports'.nodejs/node#57366 later fixes this areav23.0.0 featureSupports tsx's module.exports unwrap semantics.

For transformed ESM required from CJS, tsx mirrors Node's export { value as "module.exports" } escape hatch. esbuild emits ESM exports as accessor descriptors; ordinary CJS object-literal exports are data descriptors. src/cjs/api/module-extensions.ts reads Object.getOwnPropertyDescriptor(exports, 'module.exports') and unwraps only when the descriptor is a getter and the file is a native require(esm) candidate. This keeps ordinary CJS objects with a literal "module.exports" key intact.

Decisions

tsx lets Node own namespace construction wherever possible. It transforms TypeScript only far enough for Node's own lexer/translator to see JavaScript that matches Node's documented internal grammar. That is why the implementation prefers transform-before-preparse and descriptor-based unwrapping over a parallel namespace construction algorithm.

Implementation history in tsx

  • 7c85303 introduced named import from CJS support: the key issue was Node namespace construction, not CJS execution.
  • 807f467 showed why decorator/TypeScript syntax must be transformed before the lexer sees it.
  • 11de737 kept export annotation on the original file URL and returned transformed source so Node can preparse named exports while preserving relative resolution and import.meta.url; it also added the parent-import-shape detection that became parentImportsCommonJsExports.
  • cf8f199 added descriptor-based module.exports unwrapping for native require(esm) parity while avoiding false positives for ordinary CJS object keys.