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 the file ending, the
typeinpackage.json, and flags to tell whether Node runs a file as CommonJS or as an ES module. - 02Load one kind from the otherExplain what a default import of CommonJS gives you, when a named import works, and what
require()gives back for an ES module. - 03Avoid package trapsSpot when a dual package splits its saved data into two copies, and choose a safer design.
Two kinds of module
A module is a JavaScript file that shares some of its values with other files. Node.js has two kinds of module:
- CommonJS is Node's older kind. A file asks for another file with
require("./tool"). It shares values by putting them onmodule.exports. - ES modules (ESM for short) are the standard kind. Browsers use them too. A file shares values with
exportand asks for them withimport.
The two kinds load files in different ways:
- With CommonJS,
require()runs the other file right away, on that line. Then it gives back that file'smodule.exports. - With ES modules, Node first reads every file and finds all the
importandexportlines. It connects them. Only then does it run the code in the files.
You learned why modules exist in Why modules?, and you practiced import & export. This lesson is for Node projects that use both kinds together. Browsers can't run CommonJS files on their own. So every CommonJS example here is about Node, and the lesson's tests run each one in real Node.
At a café you order the set menu, and the plate comes out before you do anything else. In a big kitchen, the cook first reads every order on the rail. The cook checks that everything is in stock, and only then starts cooking. When the cook changes the dish on the menu board, everyone who looks at the board sees the new dish.
- In real life: You order the set menu and the plate comes out now
- In JavaScript:
require()runs the file and gives backmodule.exportson that line - In real life: The cook reads every order before cooking anything
- In JavaScript: ES modules find every
importandexportbefore any code runs - In real life: The menu board on the wall shows today's dish
- In JavaScript: An ES module import always shows the current value
CommonJS and ES module interop means using both kinds of module together in one Node project. "Interop" is short for "working together". One kind can load the other. This lesson shows which ways work, which need care, and which fail.
How Node chooses a loader
INTERACTIVEA loader is the part of Node that reads a file and runs it. Node has a CommonJS loader and an ES module loader. Before it runs a file, Node picks one of them.
Node does not look for import or require inside the file to decide. It checks these things, in this order:
- A flag such as
--input-type=module. This is only for code you pass to Node as text, which has no file name. - The file ending.
.cjsalways means CommonJS..mjsalways means ES module. - For a
.jsfile, thetypefield in the nearestpackage.jsonfile."module"means ES module."commonjs", or notypeat all, means CommonJS.
.cjs always means CommonJS, and .mjs always means ES module. A .js file follows the type field in the nearest package.json.Try it below. Each button is one file and the type in its nearest package.json. The code on the left is a short copy of Node's rules, and the line that decides is highlighted.
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 .js file follows the type in the nearest package.json. Here it says module.
app.js runs as ES module. A .js file follows the type in the nearest package.json. Here it says module.
Each row of this table is one file. Read across to see which loader Node uses for it.
| File | The type in the nearest package.json | Which loader Node uses |
|---|---|---|
tool.cjs | Any type, or none. | CommonJS. The .cjs ending decides. |
tool.mjs | Any type, or none. | ES module. The .mjs ending decides. |
tool.js | "module" | ES module. |
tool.js | "commonjs", or no type at all. | CommonJS. |
So one project can mix both kinds. A project with type: "module" can still keep one CommonJS file named tool.cjs. A CommonJS project can keep one ES module named tool.mjs. This is why the full file name matters when you report a bug.
A package.json inside a sub-folder sets the type for the files in that folder. Code you pass as text, with node -e or through standard input, has no file ending. For that code, use the flag --input-type=module or --input-type=commonjs.
require and module.exports
STEP THROUGHNode runs each CommonJS file inside a hidden function, called the wrapper. The wrapper gives the file a few local variables:
requireis a function that loads another file.moduleis an object about this file. Itsmodule.exportsproperty holds what the file shares.exportsis a shortcut. At the start, it points to the same object asmodule.exports.__dirnameand__filenamehold the folder path and the file path of this file.
When another file calls require() on this file, it gets back module.exports.
// settings.cjs
module.exports = 3;
setTimeout(() => {
module.exports = 4;
}, 0);
// app.cjs
const value = require("./settings.cjs");
console.log(value);
// cart.cjs
module.exports = { total: 3 };
// app.cjs
const cart = require("./cart.cjs");
cart.total = 4;
console.log(cart.total);This code has two small Node examples:
- Lines 1 to 9:
settings.cjsshares the number3. A timer later setsmodule.exportsto4. Butapp.cjsprints3.requireis synchronous: it finishes on its own line, before the timer runs. Sovaluealready holds3, and the later change can't reach it. - Lines 11 to 17:
cart.cjsshares an object.app.cjssets itstotalto4and prints4. Both files hold the same object, so a change made through one name shows through the other.
Step through three cases below. Pick one with the buttons:
- First require: a file is loaded for the first time.
- Cached require: the same file is asked for twice.
- Circular require: two files ask for each other.
Step through the first time CommonJS loads a file.
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;Node keeps every file it has loaded in a cache, a store of files it already ran. It finds them by their full path. This has two effects:
- Slow setup code in a file runs only once.
- Every file that requires it gets the same
module.exportsobject. A change made by one file shows for all of them.
In a circular require, two files require each other. Node puts a file in the cache as soon as it starts running it. So the second file can get the first file's module.exports before the first file has finished filling it in.
One more trap: exports is only a shortcut to module.exports. Adding a property through the shortcut works. Pointing exports at a new object does not change what the file shares.
// broken.cjsexports.answer = 42;exports = { answer: 0 };console.log(module.exports.answer); // replacement.cjsmodule.exports = function greet(name) { return "Hello, " + name;};Line 2 adds answer through the shortcut, so it reaches module.exports. Line 3 points the shortcut at a new object, which module.exports never sees. Line 7 is the right way to share a whole new value.
exports points to a new object. module.exports still holds the original object with answer: 42, and that is what require() gets.How the two kinds load each other
INTERACTIVEAn ES module can load a CommonJS file with import. The safest way is a default import, which is an import without curly braces: import pkg from "./lib.cjs". Then pkg is the CommonJS file's whole module.exports value.
lib.cjs sets module.exports = { answer: 42 }. A default import hands you that whole object, so pkg.answer is 42.A named import uses curly braces, like import { answer } from "./lib.cjs". From a CommonJS file, it only works sometimes. Node has to find the name answer by reading the CommonJS file's text before the file runs. It can find simple lines like exports.answer = 42. It can't always find names that are added in other ways.
ES modules also share variables in a different way. An ES module import is a live binding: a read-only link to the exporting file's variable, not a copy of its value. When the exporting file changes the variable, the importing file sees the new value.
// score.mjs
export let score = 1;
export function addPoint() {
score += 1;
}
// app.mjs
import { addPoint, score } from "./score.mjs";
console.log(score);
addPoint();
console.log(score);Run in Node, this prints 1 and then 2. Line 9 prints score, which is 1. Line 10 calls addPoint(), which adds 1 to score inside score.mjs. Line 11 reads through the same live link and prints 2. Compare this with the require example above, where value kept the number it got.
You take a travel adapter to another country. Its big main socket fits your plug every time. Its small pin holes fit only the pins they were made for. A plug with an unusual pin may not go in.
- In real life: The adapter lets a plug from one country connect
- In JavaScript: ES modules can import CommonJS, and CommonJS can require some ES modules
- In real life: A small pin fits only a hole made for it
- In JavaScript: A named import from CommonJS works only if Node found that name
- In real life: The big main socket always fits
- In JavaScript: A default import is always the whole
module.exports
Try the four cases below. Each button shows the code and what current Node prints or throws. Version 22 of Node was used to check them.
// 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);A default import gets the CommonJS file's whole module.exports value.
works: A default import gets the CommonJS file's whole module.exports value.
Each row of this table is one way to cross between the two kinds. Read across to see what you get and what to watch for.
| What you write | What you get | What to watch for |
|---|---|---|
| An ES module default-imports a CommonJS file. | The exact module.exports value. | Nothing. This always works. |
| An ES module uses a named import from a CommonJS file. | Only the names Node could find in the file's text. | Some ways of writing module.exports hide the names. |
| A CommonJS file requires an ES module. | The module's namespace object: one object with all its exports. | Only in current Node. It fails if the module uses top-level await. |
An ES module needs require. | A require made with createRequire(import.meta.url). | ES modules have no require of their own. |
Using require on an ES module
NODE 22Older advice says "CommonJS can't require ES modules." Current Node is more exact. A CommonJS file can require() an ES module, as long as that module doesn't use top-level await. Top-level await is an await written outside any function, so the whole module pauses there.
require must finish on its own line. It can't pause and wait. So if the ES module, or a file it imports, uses top-level await, Node throws the error ERR_REQUIRE_ASYNC_MODULE.
This feature is new. Node added it in version 20.17.0 behind a flag, a command-line option you had to turn on. It works without a flag from Node 20.19.0 and 22.12.0. A project can still turn it off with --no-experimental-require-module. Check which Node version your server runs before you rely on it.
// tool.mjs
export const value = 7;
export default "esm";
// app.cjs
const namespace = require("./tool.mjs");
console.log(namespace.default, namespace.value);
// async-tool.mjs
await Promise.resolve();
export const ready = 9;
// async-app.cjs
async function loadTool() {
const namespace = await import("./async-tool.mjs");
console.log(namespace.ready);
}
loadTool();This code has two Node examples:
- Lines 1 to 7:
tool.mjshas no top-levelawait. Sorequiregives back its namespace object: one object that holds all the module's exports. Line 7 printsesm 7. The default export is onnamespace.default, and the named exportvalueis onnamespace.value. - Lines 9 to 18:
async-tool.mjsuses top-levelawaiton line 10, sorequirewould fail. Instead, line 15 usesimport(), written like a function call. It gives back a promise, andawaitwaits for it. Line 16 prints9.
Both examples run in Node, not in this page's code editor.
In current Node, require() works on an ES module without top-level await. It fails on a module with top-level await. For that case, use import() inside an async function. The Dynamic import & top-level await lesson covers import().
The namespace object from require("./tool.mjs") has the same shape as the object from import * as ns:
- The default export is on
ns.default. - Each named export is on its own name, like
ns.value. - There is no
exportsshortcut that you could change, as there is in CommonJS.
If you can't be sure that a package has no top-level await, use import(). It gives back a promise, so the CommonJS file can wait for the module.
Dual packages: one package, two files
INTERACTIVEA package can give different files to different users. It lists them in the exports field of its package.json, called the exports map. Each key in the map is a condition:
"import"names the file for code that usesimport."require"names the file for code that usesrequire."default"names the file for anything else.
A package with one file for import and another for require is called a dual package. This helps while a package moves from CommonJS to ES modules. But there is a risk. If one app loads the package both ways, Node runs two separate files. Each file has its own variables. So any saved data, like a counter, splits into two copies. This is called the dual package hazard.
Your phone has two contact lists, and your friend Asha is in both. Asha gets a new number, and you change it in one list. The other list still shows her old number. Now you can't be sure which number is right.
- In real life: Two lists that both have your friend Asha
- In JavaScript: An
importfile and arequirefile from one package - In real life: You change Asha's number in one list
- In JavaScript: The ES module counter goes up
- In real life: The other list still has the old number
- In JavaScript: The CommonJS counter doesn't change
import gets counter.mjs and require gets counter.cjs. Each file keeps its own count, so one shows 2 and the other shows 1.In the lab below, pick who loads the package. Node reads the conditions from top to bottom and uses the first one that fits. The panel on the right also shows the two counters: the CommonJS file added 1 once, and the ES module file added 1 twice.
{ "name": "address-book", "exports": { "import": "./counter.mjs", "require": "./counter.cjs", "default": "./counter.mjs" }}This user gets ./counter.mjs, from the "import" condition. If the import user and the require user get two different files, each file keeps its own counter.
There are two safer designs:
- Make one file a thin wrapper: a small file that only loads the other file and shares what it got. Then both ways use one copy of the code and its data.
- Publish only an ES module, if all your users can load ES modules.
The goal is one copy of the code for each piece of saved data.
Node helpers in each kind of module
CommonJS files get three local helpers that ES modules don't: require, __dirname, and __filename. In an ES module in Node, use these instead:
import.meta.dirnamefor the folder path.import.metais an object with facts about the current module.import.meta.filenamefor the file path.createRequire(import.meta.url), fromnode:module, when you really need a CommonJSrequire.
// 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);Line 2 runs in a CommonJS file and prints function string string. Lines 5 and 6 make a require inside an ES module. Line 7 prints the folder path and the file path. Line 8 prints function.
Node added import.meta.dirname and import.meta.filename in version 20.11.0. On older Node, build the paths from import.meta.url with the fileURLToPath and dirname functions.
These are Node tools, not browser tools. A browser ES module has import.meta.url, but no file paths. A browser can't run require() either, unless a bundler (a build tool that joins your files) rewrites the code first.
Where you’ll use this
You will need these rules when:
- A test tool says "Cannot use import statement outside a module". Node chose the CommonJS loader for a file that uses
import. - A package's instructions show
const tool = require("tool"), but your app uses ES modules. - Node says "Named export not found" for a CommonJS package.
- A dual package gives you two copies of something that should exist only once.
import legacy from "legacy-commonjs-package";const { parse, format } = legacy; export function render(input) { return format(parse(input));}Line 1 default-imports the CommonJS package and calls it legacy. Line 2 takes the parse and format functions out of it. This simple pattern doesn't depend on Node finding named exports, and it is easy to read.
- Find out which loader runs each file.
- Read the package's
exportsmap. - Use a default import for CommonJS packages.
- Use
import()for ES modules with top-levelawait. - If a package is dual, test both the
importway and therequireway.
Common misconceptions
SORTER- “Browsers run CommonJS.” Browsers run plain scripts and ES modules. CommonJS needs Node, or a build tool that rewrites it first.
- “Every CommonJS property is a named ES export.” A named import from CommonJS works only for names Node can find by reading the file's text.
- “
exports = {}replaces what the file shares.” It only points the local shortcut at a new object. Usemodule.exports = ...instead. - “
type: modulechanges every file ending.” It only changes.jsfiles..cjsand.mjsalways keep their own loader. - “Dual packages are always safe.” They are safe only when both files share one copy of the code and its data.
- “
requirekeeps a shared number up to date.” It hands over the value it finds at load time. Only a shared object stays shared, so changes to its properties show everywhere. - “An ES module import copies the value.” A named import is a live link, so later changes made by the exporting file show in the importing file.
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 with it.
Practice exercises
5 EXERCISESThis code copies what the CommonJS wrapper sets up. Read it without running it. What does it print?
const module = { exports: {} };
const exports = module.exports;
exports.answer = 42;
console.log(module.exports.answer);It prints 42. Line 3 adds answer to the object that exports points to. That is the same object as module.exports, so line 4 finds 42.
This code copies how the CommonJS cache works. Predict both values it prints.
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 a and b point to the one saved object, and its count went up once.
In Node, line 5 throws "Named export 'answer' not found". Type a safer line to use in its place.
// 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 always works, because it gets the whole module.exports object. The named import fails here: Node can't find the name answer when the file sets module.exports to a new object.
This code copies the dual package hazard with two plain objects. Predict what it prints.
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 can split the same way: its two files keep separate counters or saved data.
Which Node error code appears when app.cjs runs?
// async-tool.mjs
await Promise.resolve();
export const value = 1;
// app.cjs
require("./async-tool.mjs");Current Node throws ERR_REQUIRE_ASYNC_MODULE. The ES module pauses at its top-level await, and require can't wait for it.
Check your understanding
8 QUESTIONSQuestion 1 of 8A package has
type: modulein itspackage.json. Which loader runs a.cjsfile in it?Choose an answer to see the explanation.
Question 2 of 8This code copies what the CommonJS wrapper sets up. What does it print?
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 8An ES module default-imports a CommonJS file. What is the default value?
Choose an answer to see the explanation.
Question 4 of 8In current Node, which
require()of an ES module fails?Choose an answer to see the explanation.
Question 5 of 8This code copies how the CommonJS cache works. What does it 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 end up with two different counts of the same thing?
Choose an answer to see the explanation.
Question 7 of 8This code copies a dual package with two plain objects. What does it 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 8In an ES module, what gives you a CommonJS
require?Choose an answer to see the explanation.
Key takeaways
.cjsmeans CommonJS..mjsmeans ES module..jsfollows thetypein the nearestpackage.json.require()finishes on its own line and gives backmodule.exports. Node runs each CommonJS file once and keeps it in a cache.- A default import of CommonJS is the whole
module.exports. A named import from CommonJS works only if Node finds the name. - In current Node,
require()on an ES module gives back its namespace object. It throws if the module uses top-levelawait. - A dual package needs one shared copy of its code, or its saved data splits in two.
Final definition.
CommonJS and ES module interop is the set of rules Node uses when the two kinds of module load each other: CommonJS (require, module.exports), which runs files on the spot, and ES modules (import, export), which are connected before any code runs.
Up next: Proxy.