CommonJS & ES module interop
Learn how current Node.js chooses CommonJS or ES modules, how require and module.exports interact with import and export, why require(esm) has caveats, and how to avoid dual package hazards.
- 01Predict the loaderUse extension, package type, and flags to tell whether Node runs a file as CommonJS or ESM.
- 02Read interop safelyExplain default CommonJS imports, detected named exports, and require(esm) namespace results.
- 03Avoid package trapsRecognize dual package hazards and choose safer wrapper or ESM-only designs.
Two module worlds
Node.js grew up with CommonJS: a file calls require("./tool"), Node runs that other file immediately if needed, and the call returns module.exports. Modern JavaScript has ES modules: files declare import and export, then the loader reads and links the whole graph before module bodies run.
You already met why modules exist in Why modules?, and you practiced standard import & export. This lesson is the bridge lesson for Node projects that contain both worlds. Browsers do not run CommonJS directly; every CommonJS claim here is phrased as Node behavior and backed by real Node runs in the tests.
With CommonJS, you order the set menu and receive it before the next step. With ES modules, the loader reads all the menu items first, checks every import, then runs module code in order.
- In real life: A set menu served when you order
- In JavaScript:
require()runs and returnsmodule.exportsat run time - In real life: Menu items read before the kitchen opens
- In JavaScript: ES modules know imports and exports before code runs
- In real life: The printed menu can change
- In JavaScript: ES module bindings stay live
Where the analogy stops: A real menu does not run code. The comparison is only about when CommonJS returns and when ES modules link.
CommonJS and ES module interop means crossing between CommonJS (require and module.exports) and ES modules (import and export) in Node while keeping the loader rules, cache rules, and edge cases honest.
How Node chooses a loader
INTERACTIVEThe first question is not “does the file contain import?” The first question is “which loader did Node choose for this file?” The answer comes from explicit file extensions, the nearest package.json type field, and for string input, flags such as --input-type=module.
function decide({ file, nearestType, inputTypeFlag }) { if (inputTypeFlag) return inputTypeFlag; if (file.endsWith(".cjs")) return "CommonJS"; if (file.endsWith(".mjs")) return "ES module"; if (nearestType === "module") return "ES module"; return "CommonJS";}A nearest package.json with type module makes .js files ES modules.
app.js runs as ES module. A nearest package.json with type module makes .js files ES modules.
| File | Nearest package.json type | Node loader |
|---|---|---|
tool.cjs | Any | CommonJS |
tool.mjs | Any | ES module |
tool.js | module | ES module |
tool.js | commonjs or no type | CommonJS |
A package can be mostly ESM with type: "module" and still keep a config file as tool.cjs. Or it can be mostly CommonJS and keep one explicit tool.mjs. That is why file extension examples in bug reports matter.
require and module.exports
STEP THROUGHCommonJS wraps each file in a function that receives local variables such as require, module, exports, __dirname, and __filename. The public value is module.exports. At the beginning, exports is just a shortcut to that same object.
Replay the first synchronous CommonJS load.
script
// main.cjsconsole.log(cart.total([2, 3])); // cart.cjsconst math = require("./math.cjs");exports.total = (items) => items.reduce(math.add, 0); // math.cjsexports.add = (a, b) => a + b;The cache is both helpful and surprising. It means expensive setup runs once. It also means stateful modules share one object. During a circular require, Node has to put a module in the cache before it finishes; the other module may see a partially filled exports object.
// broken.cjsexports.answer = 42;exports = { answer: 0 };console.log(module.exports.answer); // replacement.cjsmodule.exports = function greet(name) { return "Hello, " + name;};Property writes through exports reach module.exports; reassigning exports only moves the local shortcut.
Interop rules
INTERACTIVEInterop is a plug adapter between two shapes. It works, but only the pins the adapter can see fit cleanly. The reliable direction is import pkg from "./lib.cjs": pkg is the CommonJS module.exports value. Named imports from CommonJS are a convenience, not a guarantee.
If a CommonJS file writes exports.answer = 42, Node can often detect the answer pin. If it builds an object dynamically and assigns module.exports later, the adapter may not know that answer exists as a named ES export.
- In real life: The adapter lets a plug connect
- In JavaScript: ESM can import CommonJS, and CommonJS can require some ESM
- In real life: A pin fits a matching hole
- In JavaScript: Named CJS exports need static detection
- In real life: The main plug always fits
- In JavaScript: A default import is the whole
module.exports
Where the analogy stops: A physical adapter does not inspect source code. Node's CommonJS named export bridge does, and dynamic patterns may be invisible to it.
// lib.cjsexports.answer = 42;module.exports.extra = "visible"; // app.mjsimport pkg, { answer, extra } from "./lib.cjs";console.log(pkg.answer, answer, extra); // require-esm.cjsconst ns = require("./tool.mjs");console.log(ns.default, ns.value);The default import is the whole module.exports value.
works: The default import is the whole module.exports value.
| Direction | What you receive | Caveat |
|---|---|---|
| ESM imports CommonJS default | The exact module.exports value | Reliable. |
| ESM imports CommonJS named | Statically detected properties | Not every module.exports pattern is detected. |
| CommonJS requires ESM | A module namespace object | Current Node releases only; fails for top-level await. |
ESM needs require | Use createRequire(import.meta.url) | ESM does not have require as a built-in local. |
require(esm) in current Node
NODE 22Older advice often says “CommonJS cannot require ES modules.” In current Node 22 releases, the precise rule is narrower: a CommonJS file can require() a synchronous ES module and receives its module namespace object. If the ES module graph uses top-level await, synchronous require cannot wait for it and Node throws ERR_REQUIRE_ASYNC_MODULE.
Say “in current Node releases, require(esm) works for synchronous ES modules and fails for top-level await.” If you need an async ES module from CommonJS, use dynamic import(). The Dynamic import & top-level await lesson covers that async tool.
The namespace object returned by require("./tool.mjs") is shaped like import * as ns: default export on ns.default, named exports on their names, and no mutable CommonJS exports shortcut.
Dual packages
INTERACTIVEPackages can publish an exports map with conditions. A consumer using import may receive one file; a consumer using require may receive another. That can be convenient during migration, but stateful code can split into two copies.
If your phone has two contact lists for the same person, updating one does not change the other. A dual package can do that with module state when import and require point at different files.
- In real life: Two contact lists for one person
- In JavaScript: An
importentry and arequireentry create separate module instances - In real life: One list gets a new number
- In JavaScript: The ESM counter changes
- In real life: The other list stays the same
- In JavaScript: The CommonJS counter did not share that state
Where the analogy stops: Files are not literally copied on disk. The hazard is runtime identity: two entry files can create two caches, counters, classes, or singletons.
{ "name": "address-book", "exports": { "import": "./counter.mjs", "require": "./counter.cjs", "default": "./counter.mjs" }}import matches the import condition and resolves to ./counter.mjs. If import and require get separate stateful files, the counters split.
Safer designs make one side a wrapper over the other, or publish ESM-only when your audience can use it. The key is one implementation for one piece of state.
Node-specific tools
CommonJS files have local helpers that ES modules do not: require, __dirname, and __filename. In modern Node ES modules, use import.meta.dirname, import.meta.filename, and createRequire(import.meta.url) from node:module when you truly need a CommonJS-style loader.
// CommonJSconsole.log(typeof require, typeof __dirname, typeof __filename); // ES moduleimport { createRequire } from "node:module";const require = createRequire(import.meta.url);console.log(import.meta.dirname, import.meta.filename);console.log(typeof require);This is Node-specific. A browser ES module has import.meta.url, but not Node’s filesystem helpers, and a browser still does not run require() without a bundler or loader transforming the code first.
Where you’ll use this
You will use these rules when a test config says “Cannot use import statement outside a module,” when a package README shows const tool = require("tool") but your app is ESM, when a package throws “Named export not found,” or when a dual package creates two copies of a singleton.
import legacy from "legacy-commonjs-package";const { parse, format } = legacy; export function render(input) { return format(parse(input));}The pattern is boring on purpose: default-import the CommonJS package, then destructure. It avoids relying on Node’s named export detection and keeps the migration easy to read.
Common misconceptions
SORTER- “Browsers run CommonJS.” Browsers run classic scripts and ES modules. CommonJS needs Node or a build step.
- “Every CommonJS property is a named ESM export.” Named CJS imports depend on static detection.
- “
exports =replaces the export.” It only reassigns the local shortcut. Usemodule.exports = .... - “
type: modulechanges every extension.” It changes.js;.cjsand.mjsstay explicit. - “Dual packages are always safe.” They are safe only when both entry points share implementation and state.
import pkg from "./lib.cjs";- import { answer } from "./lib.cjs"; // lib uses exports.answer = 42
- import { answer } from "./lib.cjs"; // lib does module.exports = { answer: 42 }
- const ns = require("./tool.mjs"); // no top-level await
- require("./async-tool.mjs"); // async-tool has top-level await
const require = createRequire(import.meta.url);
Sort each snippet by what current Node does.
Practice exercises
5 EXERCISESRun the code mentally. What does the CommonJS model print?
const module = { exports: {} };
const exports = module.exports;
exports.answer = 42;
console.log(module.exports.answer);It prints 42 because exports.answer = 42 adds a property to the same object that module.exports references.
Predict both values printed by this cache model.
const cache = {};
function load() {
if (cache.tool) return cache.tool;
cache.tool = { count: 0 };
return cache.tool;
}
const a = load();
const b = load();
a.count += 1;
console.log(a === b, b.count);It prints true 1: both names point to the cached object, and the count was incremented once.
Replace the first import line with a safer bridge.
// lib.cjs
module.exports = { answer: 42 };
// app.mjs
import { answer } from "./lib.cjs";
console.log(answer);import pkg from "./lib.cjs";
const { answer } = pkg;The default import is reliable because it receives the whole CommonJS export object. The named import depends on static detection and fails for this replacement pattern.
Predict the split counter output.
const cjsCounter = { count: 0 };
const esmCounter = { count: 0 };
cjsCounter.count += 1;
esmCounter.count += 1;
esmCounter.count += 1;
console.log(cjsCounter.count + "/" + esmCounter.count);It prints 1/2. A real dual package hazard feels similar: two entry files can hold separate counters or caches.
Which Node error code appears?
// async-tool.mjs
await Promise.resolve();
export const value = 1;
// app.cjs
require("./async-tool.mjs");Current Node throws ERR_REQUIRE_ASYNC_MODULE because require cannot synchronously return a namespace for a graph paused by top-level await.
Check your understanding
8 QUESTIONSQuestion 1 of 8Which loader runs a
.cjsfile in a package withtype: module?Choose an answer to see the explanation.
Question 2 of 8What does
exports.answer = 42update?Read the code, then predictconst module = { exports: {} }; const exports = module.exports; exports.answer = 42; console.log(module.exports.answer);Choose an answer to see the explanation.
Question 3 of 8When ESM imports a CommonJS module with a default import, what is the default value?
Choose an answer to see the explanation.
Question 4 of 8Which CommonJS-to-ESM case fails in current Node releases?
Choose an answer to see the explanation.
Question 5 of 8What does the cache model print?
Read the code, then predictconst cache = {}; function load() { if (cache.tool) return cache.tool; cache.tool = { count: 0 }; return cache.tool; } const a = load(); const b = load(); a.count += 1; console.log(a === b, b.count);Choose an answer to see the explanation.
Question 6 of 8Why can a dual package create stale state?
Choose an answer to see the explanation.
Question 7 of 8What does the dual counter model print?
Read the code, then predictconst cjsCounter = { count: 0 }; const esmCounter = { count: 0 }; cjsCounter.count += 1; esmCounter.count += 1; esmCounter.count += 1; console.log(cjsCounter.count + "/" + esmCounter.count);Choose an answer to see the explanation.
Question 8 of 8Which ES module replacement gives you a CommonJS-style
require?Choose an answer to see the explanation.
Key takeaways
.cjsmeans CommonJS;.mjsmeans ESM;.jsfollows the nearest packagetype.require()is synchronous and returnsmodule.exports; CommonJS modules are cached by resolved filename.- ESM default-imports CommonJS as the whole
module.exports; named imports from CommonJS have detection caveats. - In current Node releases,
require(esm)can return a namespace for synchronous ESM and throws for top-level await. - Dual packages need one shared implementation or they can create two copies of state.
Final definition.
CommonJS and ESM interop is Node’s set of adapter rules for crossing between synchronous CommonJS (require, module.exports) and statically linked ES modules (import, export).
Up next: Proxy.