JAVASCRIPT / MODULES AND DEVELOPER TOOLING
CommonJS require against ES module import
Decide whether a file is CommonJS or ESM, predict how each one loads and binds exported values, and fix the interop errors Node reports when they mix.
What you will learn
- Explain why import specifiers are static and hoisted while require() runs where it sits
- Predict snapshot copies from require versus live read-only bindings from import
- Name a file's module system from its extension and the nearest package.json type
- Fix ERR_REQUIRE_ESM and missing-named-export errors with the right interop form
Understanding CommonJS require against ES module import
require('./config') is nothing special: an ordinary function call that Node executes when control reaches that line. It resolves the path, reads and runs the file synchronously if the file is not already in the module cache, and returns whatever module.exports points at when the file finishes. import is not a call at all; it is a declaration the engine collects while parsing, before a single statement of your file runs. Node fetches and parses the entire graph, links every imported name to the binding that exports it, and only then evaluates the module bodies in dependency order.
That ordering explains the restrictions people find annoying. Specifiers have to be string literals because linking happens before evaluation, so there is no run-time value available to build a path from, and an import declaration cannot sit inside an if or a function for the same reason. It also explains the payoff: an imported name is a live, read-only view of the exporter's variable, so a reassignment inside the exporting module is visible to every importer, and circular graphs work as long as nobody reads a binding before it is initialized. require hands you a reference to a mutable object instead, so pulling a primitive out of it with destructuring freezes that value at call time.
Which system a file uses is not decided by the syntax you typed. Node looks at the extension first, .mjs is always ESM and .cjs is always CommonJS, and otherwise at the type field of the nearest package.json, defaulting to commonjs; your syntax then has to match, or you get a SyntaxError that reads like a typo. Crossing the boundary is asymmetric: an ES module can import a CommonJS file, where default is the whole module.exports object and named exports exist only if Node's source scan spotted them, while CommonJS has no import statement at all. ESM also drops the CommonJS wrapper variables (require, module, exports, __dirname, __filename), is always strict, and has undefined rather than module.exports as its top-level this.
// require and import differ in one thing: when a name gets connected to a value.
// Both behaviours are modelled in one file so the difference shows up in output.
let count = 0; // the exporting module's own variable
function bump() { count += 1; }
// const { count, bump } = require('./counter') -> a copy taken at call time
const required = { count, bump };
// import { count, bump } from './counter.js' -> a name re-read on each access
const imported = { get count() { return count; }, bump };
required.bump();
imported.bump();
console.log('require snapshot:', required.count);
console.log('import live binding:', imported.count);
count = 41;
console.log('after the exporter reassigns:', required.count, imported.count);Every other difference follows from when the specifier is resolved: require is a function call evaluated in place that returns a value, while import is a declaration the loader resolves and links before your module body runs.
Worked examples
The require cache in fifteen lines
Models why require can run anywhere, runs a file body only once, and hands every consumer the same mutable exports object.
// A miniature CommonJS loader: one cache, and module bodies that run on demand.
const registry = {
'./config': (module) => {
console.log('evaluating ./config');
module.exports = { port: 8080 };
},
};
const cache = new Map();
function require(id) {
if (cache.has(id)) return cache.get(id).exports;
const module = { exports: {} };
cache.set(id, module); // cached before the body runs
registry[id](module); // the file body executes here, synchronously
return module.exports;
}
const first = require('./config');
const second = require('./config');
first.port = 3000;
console.log(first === second, second.port);Example explained
Line 1The registry entry stands in for a file body, and require invokes it, so 'evaluating ./config' prints during the first call rather than before it.
Line 2cache.set runs before the body, which is exactly why a circular require returns a partially filled exports object instead of recursing forever.
Line 3first === second is true because the second call short-circuits to the cached module record; module bodies never run twice per resolved path.
Line 4first.port = 3000 mutates the single shared exports object, so every other consumer that kept the object sees 3000.
Extension and type decide the format
Encodes Node's resolution rule so you can name a file's module system without guessing from its contents.
// How Node decides which module system a file uses.
function moduleSystem(file, nearestPackageType) {
if (file.endsWith('.mjs')) return 'ESM';
if (file.endsWith('.cjs')) return 'CommonJS';
return nearestPackageType === 'module' ? 'ESM' : 'CommonJS';
}
const cases = [
['index.js', undefined],
['index.js', 'module'],
['scripts/build.cjs', 'module'],
['scripts/build.mjs', 'commonjs'],
];
for (const [file, type] of cases) {
console.log(`${file} with "type": ${type ?? '(absent)'} -> ${moduleSystem(file, type)}`);
}Example explained
Line 1The two extension checks come first because .mjs and .cjs pin the format no matter what package.json says.
Line 2A bare .js file is ambiguous, so the fallback is the type field, and an absent field means commonjs for backward compatibility.
Line 3Nearest means the first package.json found walking upward from the file, so a subdirectory can override the project root.
Line 4Reading this rule wrong is what produces 'Cannot use import statement outside a module' on perfectly valid syntax.
What ESM sees when it imports CommonJS
Shows the shape Node builds for a CommonJS file: default is the whole module.exports, and named exports depend on a static scan.
// What an ES module actually receives when it imports a CommonJS file.
const moduleExports = { parse: (text) => text.trim(), version: '2.1.0' };
// Node exposes module.exports as `default`, plus any names its static scan found.
const namespace = { ...moduleExports, default: moduleExports };
console.log(namespace.default === moduleExports); // import lib from './lib.cjs'
console.log(namespace.parse(' padded ')); // import { parse } from './lib.cjs'
console.log(Object.keys(namespace).join(','));
if (!('compile' in namespace)) {
console.log("missing name -> Node fails linking: Named export 'compile' not found");
}Example explained
Line 1namespace.default is the entire module.exports value, which is why a default import of a CommonJS file gives you the object or function that file assigned.
Line 2The spread models Node's source scanner: it reads the CommonJS text for simple exports.x = patterns and pre-declares those as named exports.
Line 3Names the scanner cannot see, because they are attached in a loop or a callback, are absent from the namespace.
Line 4In real Node the absence is not undefined but a link-time SyntaxError, which is why the fix is a default import followed by run-time destructuring.
Important notes
Node 20.19 and 22.12 and later can require() an ES module whose graph contains no top-level await, but you get the namespace object back, so a default export arrives as .default, and older runtimes still throw ERR_REQUIRE_ESM; library code should not depend on it.
Bundlers and TypeScript rewrite these forms during the build, so a file that works in the bundle can still fail when Node runs it directly; check the format rules against the emitted files, not the source.
Common mistakes
Writing import statements in a .js file with no "type": "module" in the nearest package.json: Node parses the file as CommonJS and reports 'Cannot use import statement outside a module', which sends beginners hunting for a typo instead of fixing the package.json.
Assuming const { count } = require('./counter') keeps up with the exporter: the destructuring copied the value once, so the number stays stuck at whatever it was when the require ran, while the same code written with import prints the updated value.
Copying a README's named-import line for a CommonJS-only package: named imports from CommonJS exist only when Node's static scan found them, and the failure happens during linking, so no try/catch around the import statement can catch it.
Try it yourself
Change, predict, then run
In a browser editor, write a makeRequire(registry) helper that runs each module body at most once and caches its module.exports, then require the same id twice, mutate the first result, and log that the second reference sees the change while the body printed only once. Then expose the same module's counter through a getter and show that a destructured copy and the getter disagree after the counter increments.
Open the JavaScript workspaceCheck your understanding
counter.cjs sets exports.count = 0 and exports a bump() that increments exports.count; counter.mjs does the same with export let count = 0. One consumer writes const { count, bump } = require('./counter.cjs'), another writes import { count, bump } from './counter.mjs'. Both call bump() twice and then log count: the first prints 0, the second prints 2. Which explanation is correct?
- Node re-evaluates the ES module on each import, so the second consumer reads a fresher copy of the module.
- Node hoists require calls to the top of the file, so the require ran before the two increments.
- Destructuring the object require returned copied the number once, while the imported name stays connected to the exporter's binding and is re-read on every access.
- ES modules store exported numbers as objects rather than primitives, so the exported value can be updated in place.
Show answer
The whole difference is value versus binding: require returns an object and destructuring pulls a primitive out of it at that instant, whereas an imported name is an alias for the exporter's variable that is read when you use it. Option 1 has the hoisting backwards, since require runs exactly where it appears and import declarations are the ones linked before any code runs, and hoisting is not what froze the number anyway. Option 0 is wrong because both systems evaluate a module only once per resolved path and then serve it from a cache.