Skip to content

Build1 publisher3 min readPublished

Ten Node.js 24 test cases separate how a file is classified from how it loads

Ten Node.js v24.15.0 test cases published on dev.to show the extension and the nearest package.json decide whether a file runs as ESM or CommonJS. Settling that before editing imports shows whether the fix is configuration or an asynchronous caller.

The Engineer · Build desk

Illustration accompanying Ten Node.js 24 test cases separate how a file is classified from how it loads

What happened

  • With an empty package.json, a .js file containing an export ran through syntax detection, printed 42 and raised a MODULE_TYPELESS_PACKAGE_JSON warning.
  • One line of top-level await in a target .mjs module made a require() call that had worked fail with ERR_REQUIRE_ASYNC_MODULE.
  • A CommonJS file that loaded the async module through import() got its value and kept require, printing function 42.
  • An ESM import of ./sync failed with ERR_MODULE_NOT_FOUND even with sync.mjs beside it, because Node requires extensions on relative specifiers.

Compiled by The EngineerSomething wrong?How this is made

Why it matters

  • decision Silencing the typeless warning with type: module reclassifies every .js file under that package.json, so a one-file migration is better done by renaming to .mjs or .cjs.
  • cost Moving a require() of an async ES module to import() means awaiting a promise. The caller's own interface may then have to become asynchronous.
  • exposure Relative imports without extensions that a bundler accepts can fail once the same code is handed straight to Node.js.

The post opens with a sequence that turns up wherever ESM and CommonJS coexist. You get `require is not defined`, swap in an `import`, and then hit a syntax error in a different file [1]. The author's advice, in the write-up on dev.to, is to ask first how Node.js classifies the file and only then how that file loads another module [2]. Each question has its own fixes: the package.json, a filename extension, or the asynchronous boundary of the caller [2].

Classification comes from files on disk. Node's Packages documentation treats `.mjs` as ESM and `.cjs` as CommonJS. For `.js`, the `type` field of the nearest parent package.json decides [5]. The word "nearest" matters. In the test, `require()` failed in a `.js` file under an outer `type: module` directory. In `legacy.cjs` next to it, and in a `.js` file inside a nested `type: commonjs` directory, `typeof require` returned `function` [6]. The root package.json is the first one anyone opens. In this layout it was the wrong one to read for the nested file [6]. The reverse also held. A static `export` in a `.js` file under `type: commonjs` was a syntax error, while an `.mjs` file in the same area ran [7].

On current Node, leaving `type` out does not lock a `.js` file to CommonJS. The documentation describes syntax detection: input without an explicit classification can be treated as ESM when it contains ESM-only syntax [8]. In my view the typeless warning is useful, because it marks a file whose format Node inferred from its contents [9].

Loading has its own rules. The CommonJS docs for v24.15.0 set conditions under which `require()` can load synchronous ESM, and a plain `require` of an `.mjs` file returned its export, 42 [12]. The docs also exclude synchronous loading when top-level await appears anywhere in the imported module graph. The experiment only put it in the target module itself [14]. I'd expect the graph rule to cause the harder failures: a dependency several levels down adds an `await`, and a `require()` caller that never changed stops loading [14].

A CommonJS file that calls `import()` stays CommonJS. `require` was still defined after the dynamic import resolved [15].

The `ERR_MODULE_NOT_FOUND` case is a third category. It is a resolution rule, and it applies whether the target is ESM or CommonJS [18].

The method is what I would copy. Each of the 10 cases ran in its own process, with no bundler, TypeScript transformation or custom loader, and with `NODE_OPTIONS` removed from the child processes [3]. A script creates a temporary directory and asserts the exit code and output of every case [20]. The results are specific to v24.15.0 [4]. They describe another project only if it loads modules the same way: plain Node, no loader, and no flags injected through the environment [3].

What to watch

  • A rerun of the reproduction script on Node.js v26.8.2, the version the documentation already displayed, to see whether all 10 cases still hold.
  • A measurement of syntax detection's performance cost, which this experiment did not take; it checked only execution outcomes and the warning.
Loading claim ledger
Loading source directory links
Loading share composer
Loading topic controls
Loading related stories