Native Support for CJS/ESM Interoperability Begins in Node.js 22

Zachary Lee - May 23 - - Dev Community

The adoption of ECMAScript Modules (ESM) for development has become mainstream, yet Node.js continues to use the traditional CommonJS (CJS) format. This has posed challenges for library and application developers. However, with the recent release of version 22, Node.js has begun to experimentally support ESM natively, and this support is expected to stabilize in future versions. Although current support has its limitations and is not yet perfect, it is sufficient to improve the existing situation. This article will provide a detailed overview.

Understanding ESM and Its Synchronous Capabilities

ECMAScript Modules (ESM) are an official standard for structuring and loading modules in JavaScript. Unlike CJS, which executes synchronously, ESM was designed with the capability to load asynchronously. This design supports top-level await commands, allowing modules to handle asynchronous operations before they are used.

Despite this capability, ESM can also be executed synchronously if the module graph (the structure consisting of all the modules and their dependencies) does not contain any top-level await.

Hold on, you might be as puzzled as I am — aren’t ESM modules executed asynchronously?

Indeed, the ESM standard is mentioned in the TC39 specifications, where it is designed to be conditionally asynchronous — only when the module graph contains a top-level await.

Well, now that we know this, understanding the dual functionality of ESM — both synchronous and asynchronous — is crucial for comprehending the new features in Node.js.

Node.js Enhancement: Synchronous require() for ESM

The recent update in Node.js introduces an experimental feature that allows require() to synchronously load ESM graphs that do not contain top-level await. This feature is significant because it allows developers to use require() with ESM.

How It Works

When enabled via the --experimental-require-module flag, Node.js can load an ESM if it meets the following conditions:

  1. The module must be explicitly marked as an ES module either through a "type": "module" field in the nearest package.json or by using the .mjs file extension.

  2. The module must be fully synchronous, meaning it contains no top-level await.

Under these conditions, require() can load the ESM and return the module namespace object directly, much like the dynamic import() statement, but executed synchronously.

Code Example: Synchronous Module Loading

// Define a module in a file named 'math-utils.mjs'
export function square(x) {
  return x ** 2;
}
export function cube(x) {
  return x ** 3;
}

// main.js - ​Synchronously require the ES module in a CommonJS file
// $ node --experimental-require-module main.js

const mathUtils = require('./math-utils.mjs');

console.log(mathUtils.square(2)); // Output: 4
console.log(mathUtils.cube(3)); // Output: 27
Enter fullscreen mode Exit fullscreen mode

Practical Implications and Benefits

The ability to require() ESM synchronously has several practical implications:

  1. Interop Simplicity : It simplifies the interoperation between CJS and ESM, reducing the complexity of adopting ESM for projects that still rely heavily on CJS.

  2. Backward Compatibility : It provides a smoother transition for libraries to move from CJS to ESM without breaking changes for consumers still using require().

  3. Module Size Reduction : It aids in reducing the size of node_modules by avoiding the need for dual module packaging (CJS and ESM), which is common in current practices.

Challenges and Limitations

While this feature is a significant step forward, it comes with its own set of challenges and limitations:

  • Top-Level Await :

Modules that use top-level await cannot be loaded with require() and will throw an ERR_REQUIRE_ASYNC_MODULE error. This limitation necessitates using await import() for such modules. For example:

await new Promise(res => setTimeout(res)); // top-level await

export function square(x) {
  return x ** 2;
}
export function cube(x) {
  return x ** 3;
}

// Error: require() cannot be used on an ESM graph with top-level await. 
// Use import() instead. To see where the top-level await comes from, use --experimental-print-required-tla.

const mathUtils = require('./math-utils.mjs');

console.log(mathUtils.square(2)); // Output: 4
console.log(mathUtils.cube(3)); // Output: 27

// Pass:
void (async () => {
  const mathUtils = await import('./math-utils.mjs');

  console.log(mathUtils.square(2)); // Output: 4
  console.log(mathUtils.cube(3)); // Output: 27
})();
Enter fullscreen mode Exit fullscreen mode
  • Experimental Status :

Since it is still in the experimental phase, this feature may not be suitable for all production environments. There are still some feature interactions that this implementation doesn’t handle, such as --experimental-detect-module, --experimental-loader, or --experimental-wasm-modules. Some edge cases involving cycles might exhibit undefined behaviors.

Conclusion

The introduction of synchronous require() support for ESM in Node.js marks a pivotal development in JavaScript's modular architecture. However, if you are wondering why it took so long to start supporting ESM, you can find clues in this issue. Finally, with the active participation of the community, Node.js is very likely to achieve perfect support for ESM.

If you find this helpful, please consider subscribing to my newsletter for more insights on web development. Thank you for reading!

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .