DEV Community

hiro@
hiro@

Posted on Originally published at zenn.dev

Debugging mixed ESM and CommonJS: separate module classification from loading

You see require is not defined, replace it with an import, and then encounter a syntax error in another file. When ESM and CommonJS coexist, changing syntax alone can make the original cause difficult to follow.

First ask how Node.js classifies the file. Then ask how that file loads another module. Separating these questions helps determine whether to change package.json, a filename extension, or the asynchronous boundary of the caller.

This investigation ran 10 small-file cases in separate processes on Node.js v24.15.0. It used no bundler, TypeScript transformation, or custom loader, and removed NODE_OPTIONS from child processes. Official documentation was checked on September 11, 2026; the latest pages displayed v26.8.2. The experimental results are specifically for v24.15.0.

Check the extension and nearest package.json first

The official Packages documentation identifies .mjs as ESM and .cjs as CommonJS. For .js, the nearest parent package.json can explicitly select the system through type.

“Nearest” matters: inspecting only the repository root can miss the applicable setting. The experiment placed a child directory with type: commonjs inside one configured with type: module.

esm/
  package.json            type: module
  require.js
  legacy.cjs
  nested/
    package.json          type: commonjs
    legacy.js
Enter fullscreen mode Exit fullscreen mode

Calling require() inside require.js failed. In both legacy.cjs and nested/legacy.js, however, typeof require produced function. The extension or the inner configuration can select CommonJS even inside an outer ESM package area.

The reverse was checked too. A static export in a .js file under type: commonjs produced a syntax error, while an .mjs file in the same area executed successfully.

An untyped js file is not necessarily CommonJS

Assuming that omitting type always preserves the old CommonJS behavior is incomplete for current Node.js. The documentation describes syntax detection: input without an explicit classification can be treated as ESM when it contains ESM-only syntax.

The experiment ran this .js file in a directory containing an empty package.json:

export const value = 42;
console.log(value);
Enter fullscreen mode Exit fullscreen mode

It printed 42 and emitted a MODULE_TYPELESS_PACKAGE_JSON warning. The same export failed with an explicit type: commonjs, but succeeded through syntax detection when type was absent.

Adding type: module simply to remove that warning also affects existing .js files governed by the same setting. First identify the target file and the files sharing that configuration. For a localized migration, .mjs and .cjs can make the intention explicit.

The performance cost of syntax detection was not measured. This experiment checked execution outcomes and the warning.

require can load some ES modules

The older rule “CommonJS must always use import() to load ESM” also needs qualification. The CommonJS documentation for the tested version describes the conditions under which require() can load synchronous ESM.

The target ES module contained:

export const value = 42;
Enter fullscreen mode Exit fullscreen mode

The CommonJS caller read the named export from the returned object:

console.log(require("../esm/sync.mjs").value);
Enter fullscreen mode Exit fullscreen mode

It printed 42. An .mjs extension alone does not establish that require() must reject the module.

Changing the target to this made require() fail with ERR_REQUIRE_ASYNC_MODULE in the same environment:

await Promise.resolve();
export const value = 42;
Enter fullscreen mode Exit fullscreen mode

The difference is top-level await. The documentation also excludes synchronous loading when top-level await occurs elsewhere in the imported module graph. The experiment placed it directly in the target module.

Dynamic import does not turn its caller into ESM

The CommonJS file then loaded the asynchronous ES module like this:

import("../esm/async.mjs").then(m => console.log(typeof require, m.value));
Enter fullscreen mode Exit fullscreen mode

The output was function 42. It waited for the ES module and obtained its value, while require remained available in the caller. Adding an import() expression did not switch the entire CommonJS file into ESM.

However, replacing a synchronous load with import() means waiting for its result. The caller may also need an asynchronous interface. Distinguish a configuration problem from a change that requires an asynchronous boundary.

Correct classification does not guarantee correct path resolution

An ESM relative import of ./sync failed with ERR_MODULE_NOT_FOUND even though sync.mjs was next to it. The ESM documentation requires extensions for relative and absolute import specifiers.

That is separate from whether the target is ESM or CommonJS. This example needs ./sync.mjs. A shorthand accepted by a bundler need not work when passed directly to Node.js.

The 10 checked cases were:

Condition Result
require in a .js file under type: module require is not defined
Static export in .js under type: commonjs Syntax error
.cjs under type: module Runs as CommonJS
.mjs under type: commonjs Runs as ESM
.js beneath an inner type: commonjs package Uses the inner setting
CommonJS requires synchronous ESM Retrieves the value
CommonJS requires ESM with top-level await ERR_REQUIRE_ASYNC_MODULE
CommonJS dynamically imports that ESM Retrieves the value; require remains available
An ESM relative import omits the extension ERR_MODULE_NOT_FOUND
A .js file with no type contains export Runs through syntax detection, with a warning

The reproduction script creates a temporary directory, asserts exit codes and output for every case, then removes it. It does not change an existing project's package.json.

Conditional package exports, CommonJS named-export detection, and TypeScript configuration are outside this experiment. For an initial investigation of mixed-module errors, check the Node.js version, file classification, loading method, and relative path in that order.

Top comments (0)