Skip to content

fix(core): run files whose only imports and exports are type-only as CommonJS - #910

Open
darcyrush wants to merge 1 commit into
oxc-project:mainfrom
darcyrush:fix/type-only-commonjs
Open

darcyrush wants to merge 1 commit into
oxc-project:mainfrom
darcyrush:fix/type-only-commonjs

Conversation

@darcyrush

Copy link
Copy Markdown

AI generated, but ran the fix against a dummy repo locally.

Fixes #909

Summary

Type erasure removes import type and the other type-only declarations whole, so Node.js' own type
stripping runs a file whose only module syntax they are as CommonJS. oxc-node ran it as an ES module,
without require, module or exports.

shape ("type": "commonjs" unless noted) before after
import type + require, as the entry point, require()d, or imported from an .mts file ESM: require is not defined in ES module scope CommonJS
export type { … } (± from), export type *, export type X =, export interface, export default interface, export declare … ESM CommonJS
package without "type" ESM: ReferenceError CommonJS
import type + export = TS(1203); require(): module is not defined in ES module scope CommonJS
import { type X }, a value export, import.meta, top-level await, export {} ESM ESM (unchanged)
.mts, "type": "module" ESM ESM (unchanged)

Fix

  • parsed_as_esm and has_module_declaration no longer count an import/export declaration that
    type erasure removes whole (is_type_only_module_declaration): import type, export type { … }
    with or without from, export type *, and the export of a type alias, an interface or an ambient
    declaration. import { type X } from "x" still counts, because it leaves import {} from "x" behind,
    which is module syntax to Node.js.
  • oxc_transform compiles such a file the way a .cts file is already compiled. The empty export {} the transform appends is dropped, and helpers are require()d. This also changes the public
    transform() API, whose output for such a file no longer ends in export {};.

On the #759 test

This replaces the test a file whose only module syntax is a type-only import runs as an ES module
from #759, which says:

import type is erased, but the transform injects export {} so that the output stays a module —
the CommonJS path never could run such a file (it crashed with ERR_REQUIRE_CYCLE_MODULE), so flipping
to ESM is the only working behaviour. CJS globals were never available to it either way.

That held while the output kept the injected export {}. Compiled as a .cts file is, the output has
no module syntax left, so the CommonJS path runs it. The file then gets require, module and
exports. That matches what Node.js' type stripping and tsc give it, and a file written as CommonJS
needs them: today it fails with require is not defined in ES module scope.

A file with module syntax left after erasure still runs as an ES module, exactly as #759 made it. The
new guard tests pin that.

Tests

In cjs-esm-syntax.spec.ts:

  • A file with every type-only form, in a "type": "commonjs" package, stays CommonJS as an entry
    point, when imported from an .mts file, and when require()d.
  • The same in a package without "type".
  • import type beside export = stays CommonJS.
  • Guards: import type beside an inline type specifier, a value export, import.meta or top-level
    await still runs as an ES module.

With the src/lib.rs change reverted, the 5 new tests fail. The 4 guards pass either way.

Verification

Node.js v26.12.1, linux-x64: vp run test (599 passed, 8 skipped), cargo test --all-features,
cargo clippy --all-targets --all-features -- -D warnings and cargo fmt --check all pass. In a
dummy repo, the 7 type-only forms run as CommonJS, as node file.ts runs them, and the 5 guards still
run as ES modules.

Not covered

  • Under module: node16, node18 or nodenext, the resolve hook reports every .ts file as an ES
    module before the load hook sees it, so this fix needs fix(core): let package.json "type" decide .ts format under node* modules #908 there.
  • A named import from an ES module (import { x } from "./file.ts") of such a file still fails with
    does not provide an export named, as it does on main. The load hook hands CommonJS files back to
    Node.js without a source, and cjs-module-lexer then reads the untransformed .ts, where the import type line stops it. A default import and require() work.
  • A namespace that holds only types (export namespace N { export type T = number }) still counts as
    module syntax, although Node.js strips it.

@darcyrush
darcyrush force-pushed the fix/type-only-commonjs branch from 9e55fb1 to bda8cec Compare November 6, 2026 16:28
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.

.ts files whose only imports and exports are type-only run as ES modules, without require or module

1 participant