Skip to content

feat(core): compile ES module syntax to CommonJS where tsc would (#905) - #911

Open
darcyrush wants to merge 1 commit into
oxc-project:mainfrom
darcyrush:feat/commonjs-lowering-tsc-parity
Open

darcyrush wants to merge 1 commit into
oxc-project:mainfrom
darcyrush:feat/commonjs-lowering-tsc-parity

Conversation

@darcyrush

@darcyrush darcyrush commented Nov 6, 2026 •

Copy link
Copy Markdown

AI generated, but ran the fix against a full live CJS project. The project uses package.json type as commonjs with all file extensions as .ts running NestJS 12 (ESM) and tests mocked using nodes test module mock.method.

I needed to upgrade to TypeScript 7.0.2 from 5.9.3 which ts-node doesn't support. tsx and other don't support legacy TypeScript decorators (or rather emitDecoratorMetadata, which NestJS uses). I'm not aware of any other JIT that supports CJS projects needing emit decorator metadata on TS7+.

Closes #905

Problem

In a "type": "commonjs" package, a .ts file that uses import/export runs as an ES module under oxc-node, even when tsconfig compiles it to CommonJS. Its namespace object is then frozen, so mock.method() from node:test can't replace its exports. Under tsc it can.

package.json "type": "commonjs", tsconfig "module": "node20"
test.ts: import * as lib from "../src/lib"; mock.method(lib, "compute", …)

                    main                                    this PR   tsc
oxnode --test       TypeError: Cannot redefine property     pass      pass

Node.js' own type stripping doesn't run this file as an ES module either: it rejects it with SyntaxError: Cannot use import statement outside a module.

Fix

A file with ES module syntax is compiled to CommonJS the way tsc compiles it when all of these hold:

  • its package is "type": "commonjs"
  • its tsconfig module is commonjs, node16, node18, node20 or nodenext (any case)
  • its extension is ambiguous: .ts, .tsx, .js, .jsx or none
  • it uses neither import.meta nor top-level await, which have no CommonJS form
  • it declares none of the names the CommonJS wrapper or the helpers below use: exports, require,
    module, __filename, __dirname, __importDefault, __importStar, __exportStar

Anywhere else, such as a package with no "type", "type": "module", or an ES module setting, the file runs as an ES module, as before. Switching a project's "type" to "module" turns the compilation off.

The output has tsc's shape. It's built from the module runner transform's output:

  • An export of a local that is never reassigned is a plain data property (exports.a = void 0; … exports.a = a;). It's writable, so mock.method(namespace, "a") reaches every importer. A hoisted function is exported up front.
  • Reassigned locals and re-exports stay live through getters, in the form cjs-module-lexer detects.
  • A named import is require()'s result. Default and namespace imports go through __importDefault/__importStar, export * through __exportStar, and import() stays import().
  • On the load-hook path the compiled source is returned with format: "commonjs", so an ES module importer gets the named exports.

The compiled code require()s ./lib.js where the source imported ./lib.js. So Module._resolveFilename in register.mjs falls back to the TypeScript source (.js → .ts/.tsx, .jsx → .tsx/.ts, .mjs → .mts, .cjs → .cts). For a TypeScript importer outside node_modules, it also prefers the source over compiled output sitting beside it, as the ES module resolver does since #840. The hook thread's helper tag is registered on the main thread too, so the compiled files can require() their helpers.

Under node16, node18 and nodenext, the resolve hook no longer reports every .ts file as an ES module; the package's "type" decides, as it does for tsc. This is the same change as #908. Whichever PR lands second drops it.

Behaviour changes

These apply only to projects with "type": "commonjs" and one of the module settings above:

  • A file with import/export now runs as CommonJS. An ES module's default import of it is module.exports, with the file's default export at .default, the same as TypeScript types it under nodenext.
  • An ES module importer doesn't see names the file re-exports with export * from another TypeScript file: Node.js only follows re-exports into .js and .cjs files.
  • Under node16/node18/nodenext, a .ts file without module syntax is CommonJS, also when an ES module imports it.
  • require() of an ES module from a compiled file loaded through the module hooks needs esm: populate separate cache for require(esm) in imported CJS nodejs/node#59679 (22.22.3 on the 22.x line). Earlier versions fail with TypeError: Cannot read properties of undefined (reading 'exports').

The README has a new "ES module syntax in a CommonJS package" section covering this.

Tests

  • Rust: commonjs_lowering covers each module setting, the output shape, and the cases that stay ES modules (import.meta, reserved names, esnext, preserve, .mts, a package without "type").
  • New packages/integrate-commonjs package ("type": "commonjs", module: nodenext, run with
    oxnode --test), 15 tests:
    • mocking a namespace export, a function and a class, with importers seeing the mock
    • live reassigned exports
    • default, star and CommonJS imports
    • .js names finding .ts, with the source preferred over compiled output beside it
    • an ES module importer's named and default imports
    • import cycles
    • stack trace lines
    • import.meta and top-level await staying ES modules
    • runtime helpers
  • commonjs.test.ts fails to load on main. helpers.test.ts passes on both and guards what must not change.
  • The Clarify module resolution with .ts files using import and package.json type commonjs #905 repro passes on Node.js 26.12.1 and 22.22.1. pnpm test (591 vitest tests plus the three node --test packages), cargo test, clippy and fmt pass.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Clarify module resolution with .ts files using import and package.json type commonjs

1 participant