03 · Modules: ES Modules & CommonJS¶
Node has two module systems living side by side:
- ES modules (ESM) — the JavaScript standard:
import/export. This is what you should write for new code, and what this course uses throughout. - CommonJS (CJS) — Node's original system:
require()/module.exports. A huge amount of the npm ecosystem still ships CommonJS, so you need to read it and interoperate with it.
How Node decides which system a file uses¶
Node looks at the file extension first, then at the nearest package.json:
| File | Treated as |
|---|---|
*.mjs |
always ESM |
*.cjs |
always CommonJS |
*.js with nearest package.json containing "type": "module" |
ESM |
*.js with "type": "commonjs" or no type field |
CommonJS (with a fallback, below) |
Recent Node versions also detect ESM syntax: if a .js file with no type field
contains import/export and fails to parse as CommonJS, Node retries it as ESM. Do not
rely on that — it costs a double parse and is ambiguous to readers and tools. Set
"type": "module" explicitly:
ESM syntax you will actually use¶
export const PI = 3.14159;
export function area(r) { return PI * r * r; }
export default function describe() { return 'geometry helpers'; }
import describe, { area } from './math.mjs'; // default + named
import * as math from './math.mjs'; // namespace object
import { readFile } from 'node:fs/promises'; // built-in, with node: prefix
const config = JSON.parse(await readFile(new URL('./config.json', import.meta.url), 'utf8'));
const { default: lazy } = await import('./math.mjs'); // dynamic import, returns a promise
Rules that trip people up in ESM:
- Relative imports need the file extension:
'./math.js', not'./math'. ESM resolution in Node does not guess extensions or look forindex.js. - No
__dirname,__filename, orrequireby default. Useimport.meta.dirnameandimport.meta.filename(available in current Node releases), orimport.meta.urlwithnew URL(...). - Top-level
awaitworks — handy for loading config at startup. - ESM is always in strict mode.
CommonJS syntax you need to read¶
const path = require('node:path');
exports.greet = (name) => `hello, ${name}`;
exports.version = 1;
// or: module.exports = { greet, version };
require is synchronous and can be called anywhere, including inside if blocks.
__dirname and __filename exist in every CommonJS module.
Interop in both directions¶
ESM importing CommonJS works out of the box. module.exports becomes the default
export, and Node statically analyzes the CJS source to offer named exports where it can:
import legacy, { greet } from './legacy.cjs';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url); // a real require() inside ESM
const again = require('./legacy.cjs');
console.log(greet('esm'), legacy.version, again === legacy);
again === legacy is true: both routes hit the same cached module instance.
CommonJS requiring ESM used to be impossible (you had to use await import()).
Current Node releases support require() of an ES module as long as that module graph
does not use top-level await:
On older Node versions this throws ERR_REQUIRE_ESM. If you publish a library that must
support older runtimes, keep that in mind.
Worked example: live bindings¶
ESM exports are live bindings, not copies:
With CommonJS, const { count } = require('./counter') would copy the number 0 at
require time and never see later changes. In ESM, count is a read-only view onto the
exporting module's variable. You cannot assign to it from the importer (count = 5
throws a TypeError).
How It Actually Works¶
Resolution. For a bare specifier like import express from 'express', Node walks up
the directory tree from the importing file: ./node_modules/express,
../node_modules/express, ../../node_modules/express, and so on to the filesystem
root. In the package it finds, it reads package.json:
- If there is an
"exports"field, it is authoritative. It maps subpaths and conditions ("import","require","node","default") to files. Anything not listed is not importable —import 'pkg/internal/x.js'fails withERR_PACKAGE_PATH_NOT_EXPORTED. - Otherwise Node falls back to
"main"(and for CommonJS, toindex.js).
This is how a single package can ship both formats ("dual packages"): "exports":
{ "import": "./esm/index.js", "require": "./cjs/index.cjs" }.
Loading. CommonJS wraps each file in a function
(function (exports, require, module, __filename, __dirname) { ... }) and runs it
synchronously; that's where those "globals" come from. The result is stored in
require.cache keyed by absolute path, so the second require returns the same object.
ESM loads in three phases: construction (fetch and parse every module in the graph,
finding import statements statically), instantiation (wire each import to the
exporting module's binding — the live-binding mechanism), and evaluation (run module
bodies, dependencies first). Because imports are known before any code runs, tools can
tree-shake them and Node can report a misspelled named import before executing anything.
ESM instances are cached in a module map keyed by URL (file:///...), so every importer
shares one instance.
Common mistakes¶
- Missing extensions in ESM imports →
ERR_MODULE_NOT_FOUND. Write./utils.js. - Using
__dirnamein ESM →ReferenceError. Useimport.meta.dirname. - Mixing
requireandimportin the same.jsfile. A file is one or the other. - Default-import confusion with CJS packages. Some CJS packages set
exports.default; importing them from ESM can give you{ default: fn }. Check what you received withconsole.log. - Relying on module-level state as "per request" state. Modules are singletons per process; a module-level variable is shared by every request.
Exercise¶
- Create a project with
"type": "module". Writelib/strings.jsexportingslugify(default) andcapitalize(named), and import both fromindex.js. - Add a
legacy.cjsthat exports an object withmodule.exports = {...}. Import it from ESM, thenrequire()your ESM file from another.cjsfile. Note which named exports are visible. - Add an
"exports"field to yourpackage.jsonexposing only.and./strings. Install your package into another folder withnpm install ../pathand confirm that deep imports outsideexportsare rejected.