JavaScript Modules (ESM/CJS) Cheat Sheet
Covers ES module import/export syntax, CommonJS require/module.exports, Node.js module configuration, and interoperability between the two systems.
ES Modules (ESM)
The standard, static module system.
// math.jsexport const PI = 3.14159;export function add(a, b) { return a + b; }export default function multiply(a, b) { return a * b; }// app.jsimport multiply, { PI, add } from "./math.js"; // Default + named importsimport * as math from "./math.js"; // Namespace importexport { add as sum }; // Re-export with renameexport * from "./math.js"; // Re-export everything// Note: ESM import paths require file extensions in Node.js (./math.js, not ./math)
CommonJS (CJS)
Node's original, synchronous module system.
// math.jsconst PI = 3.14159;function add(a, b) { return a + b; }module.exports = { PI, add };// or: module.exports.add = add;// or a single default-like export: module.exports = add;// app.jsconst { PI, add } = require("./math.js");const math = require("./math.js"); // Whole module object// require() is synchronous and can be called conditionally/anywhere in the fileif (process.env.DEBUG) { const debug = require("./debug.js");}
Configuring Module Type in Node.js
package.json's "type" field decides how .js files are parsed.
// package.json{ "name": "my-app", "type": "module", "main": "index.js"}// "type": "module" -> .js files are treated as ESM// "type": "commonjs" (default) -> .js files are treated as CJS// Use .mjs to force ESM and .cjs to force CJS regardless of "type"
Interop Between ESM and CJS
Mixing the two systems has a few hard rules.
// Importing a CJS module from ESM: works, module.exports becomes the default exportimport pkg from "./legacy-cjs-module.js";const { someFn } = pkg;// Dynamic import works in both ESM and CJS, always returns a Promiseasync function load() { const mod = await import("./math.js"); mod.add(1, 2);}// require()-ing an ESM file from CJS throws --// ESM cannot be require()'d directly, use dynamic import() instead
Key Differences
What actually changes between the two systems.
- Loading- CJS require() is synchronous; ESM import is asynchronous under the hood
- Hoisting- ESM imports are hoisted and resolved before any code runs; CJS require() executes inline
- this at top level- undefined in ESM modules; module.exports in CJS modules
- __dirname / __filename- Available in CJS; in ESM, derive them via import.meta.url instead
- Tree-shaking- ESM's static import/export structure lets bundlers eliminate unused exports; CJS's dynamic nature mostly prevents it
- Live bindings- ESM named exports are live references to the source; CJS exports are copied values at require time
Top-Level Await
ESM modules can await at the top level; the importing module waits for it to resolve before continuing.
// config.js (ESM only -- top-level await is a SyntaxError in CJS)const response = await fetch("https://api.example.com/config");export const config = await response.json();// app.jsimport { config } from "./config.js";// The engine suspends evaluation of app.js until config.js's top-level await settlesconsole.log(config);// Multiple sibling modules with top-level await run concurrently where possible,// but a chain of imports awaiting each other serializes -- watch for slow startup
package.json "exports" Field
Controls exactly which subpaths consumers may import and lets you serve different builds per condition.
{ "name": "my-lib", "type": "module", "exports": { ".": { "import": "./dist/index.mjs", "require": "./dist/index.cjs", "types": "./dist/index.d.ts" }, "./plugin": "./dist/plugin.mjs", "./package.json": "./package.json" }}// Any subpath NOT listed here becomes unimportable from outside the package --// e.g. require("my-lib/dist/internal.js") throws ERR_PACKAGE_PATH_NOT_EXPORTED// even though the file physically exists on disk. This is "encapsulation".
Circular Dependencies: ESM vs CJS
Live bindings make ESM circular imports far more forgiving than CJS's snapshot-at-require-time exports.
// --- CJS: a.js and b.js require each other ---// a.jsconst b = require("./b.js");module.exports.value = "from a";console.log("b.value at require time:", b.value); // often undefined -- b.js was mid-execution// --- ESM: a.mjs and b.mjs import each other ---// a.mjsimport { value as bValue } from "./b.mjs";export const value = "from a";console.log("b value:", bValue); // TDZ error if accessed before b.mjs finishes initializing, // but once both finish, bindings stay LIVE and stay in sync// ESM resolves the circular graph via hoisted bindings that update automatically as// the source module's exported variable changes -- CJS instead copies a value once.
Import Attributes for JSON Modules
Non-JS resources like JSON must declare their type explicitly when imported statically.
// Static import with an import attribute (Node 18.20+/22+, replaces the older "assert" syntax)import config from "./config.json" with { type: "json" };// Dynamic equivalentconst { default: config2 } = await import("./config.json", { with: { type: "json" },});// Omitting the attribute throws:// TypeError [ERR_IMPORT_ATTRIBUTE_MISSING]: Module needs an import attribute of "type: json"// CJS has no equivalent restriction -- require("./config.json") just works,// which is one more reason JSON imports behave differently across the two systems.
Dual-Package & Resolution Gotchas
Pitfalls that show up once a package tries to support both module systems.
- Dual package hazard- If a package is loaded once via require() and once via import(), you get TWO separate module instances with separate module-level state (e.g. two different singletons)
- conditional exports order- In the "exports" map, condition keys are matched in the order they're written -- put "types" first, generic fallbacks last
- createRequire- import { createRequire } from 'module'; const require = createRequire(import.meta.url); lets an ESM file call require() when truly needed
- import.meta.url- ESM's replacement for __filename/__dirname; combine with new URL('./file', import.meta.url) or fileURLToPath()
- instanceof across duplicated instances- A dual-loaded package's class exported from both entry points produces two distinct constructors, breaking instanceof checks between them
- .mjs/.cjs override "type"- Regardless of package.json's "type" field, .mjs is always parsed as ESM and .cjs always as CommonJS
Don't mix require() and import in the same file expecting them to behave identically -- if package.json has "type": "module", every .js file is parsed as ESM and require is undefined by default; use createRequire from 'module' if you truly need CJS require inside an ESM file.