Module Formats — ESM vs CJS vs UMD, the Dual-Package Hazard
TL;DR
Three module formats you’ll encounter: ESM (import/export, the modern standard, native in browsers + Node 14+), CJS (require/module.exports, Node’s original, still common in tooling), UMD (Universal Module Definition — a CJS+AMD+global polyglot from the pre-bundler era, mostly legacy). Pure ESM is the right answer for libraries today, but you’ll ship dual (ESM + CJS) for compatibility. The dual-package hazard is real: import the same package via ESM and CJS, get two separate module instances — singletons break.
Interview Q&A
Q: Show me each format syntactically.
A:
// CJS — Node's original
const lodash = require("lodash");
module.exports = function foo() {};
module.exports.bar = function bar() {};
// ESM — modern standard
import lodash from "lodash";
import { debounce } from "lodash-es";
export function foo() {}
export default foo;
// UMD — works in CJS, AMD (RequireJS), and as a global <script>
(function (root, factory) {
if (typeof define === "function" && define.amd) define(factory);
else if (typeof module === "object" && module.exports) module.exports = factory();
else root.MyLib = factory();
}(this, function () {
return { foo: function () {} };
}));
UMD wrappers were the answer before ESM existed — one file that worked everywhere. Now mostly historical; new libraries don’t ship UMD.
Q: Why does ESM matter?
A:
- Native browser support.
<script type="module" src="...">runs ESM directly. No bundler required for prototyping. - Static analyzability. Imports/exports are declared, not runtime-computed. Enables tree shaking, dead-code elimination, type analysis.
- Top-level await. ESM modules can
awaitat the top level; CJS can’t. - Strict mode by default. No
"use strict";needed. - Live bindings. Imports reflect current values of the exporting module, not a snapshot.
CJS is dynamic — require(path) accepts variables, conditional requires. Convenient but defeats static analysis (tree shaking can’t tell what’s exported until runtime).
Q: How does package.json express both formats?
A: The exports field. Modern, conditional, explicit:
{
"name": "mylib",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
},
"./button": {
"types": "./dist/button.d.ts",
"import": "./dist/button.mjs",
"require": "./dist/button.cjs"
},
"./package.json": "./package.json"
}
}
"type": "module"marks.jsfiles as ESM by default.exportsis the authoritative entry-point map; legacymain/moduleare fallbacks.- Conditional resolution —
importfor ESM consumers,requirefor CJS. - Subpath exports —
import { Button } from "mylib/button"resolves to./dist/button.mjsfor ESM consumers. ./package.jsonexplicit export — required if anyone reads it (require("mylib/package.json")).
The exports field also encapsulates the package — only the listed paths are importable. import "mylib/internal/secret" errors. Useful for stable public APIs.
Q: What’s the dual-package hazard?
A: When a package ships both ESM and CJS builds, importing it via different routes can load two separate instances. Singletons (a shared registry, a React context, an event bus) break — each instance has its own state.
Example: in a monorepo, package A imports mylib as ESM (import x from "mylib"); package B imports it as CJS (require("mylib")). Both get a copy of mylib, with separate module state. A Symbol() registered in one is not the one the other sees.
Mitigations:
- Ship one format when feasible (pure ESM for new libs).
- Use
exportsto make consumers route consistently. - Put singletons in a shared peer dep (
react,vue) so the host resolves one instance. - Document the constraint clearly.
The hazard hits libraries with global state, registries, or things that compare by instanceof / Symbol identity. Pure functional libraries (lodash, date-fns) are unaffected — they have no state.
Q: Can you import a CJS package from ESM?
A: Yes, but named imports may not work cleanly.
// CJS module
module.exports = { foo: 1, bar: 2 };
// ESM consumer
import pkg from "cjs-pkg";
console.log(pkg.foo); // ok
import { foo } from "cjs-pkg";
// Sometimes works (Node's named-export detection), sometimes undefined.
Node tries to detect named exports from CJS by static analysis. Misses dynamic exports (module.exports[someVar] = ...). Reliable pattern:
import pkg from "cjs-pkg";
const { foo } = pkg;
Bundlers usually handle this with interop wrappers; standalone Node varies.
Q: Can you require an ESM package from CJS?
A: Not synchronously. ESM is async-resolved; require() is sync. Options:
// Async dynamic import works
const pkg = await import("esm-pkg");
// Older CJS code that needs sync — no clean answer
// (workarounds via @esbuild-kit, ts-node, etc.)
This is why some ESM-only packages (node-fetch v3, chalk v5) broke many older CJS-based codebases. They have to upgrade or stay on the older CJS version.
Q: What’s "type": "module" in package.json?
A: Tells Node (and tooling) to treat .js files in this package as ESM by default. Without it, .js is CJS.
Per-file overrides via extension:
.mjs→ always ESM..cjs→ always CJS..js→ governed by nearestpackage.json’stype.
Modern advice: set "type": "module" and use .cjs only when forced (legacy tooling, certain Node entry points). TypeScript respects this via --moduleResolution node16 or nodenext.
Q: TypeScript module emission — what controls it?
A: tsconfig.json:
{
"compilerOptions": {
"module": "ESNext", // or "CommonJS", "NodeNext"
"moduleResolution": "Bundler", // or "Node10", "Node16", "NodeNext"
"esModuleInterop": true, // smoother CJS interop in emitted JS
"verbatimModuleSyntax": true // require explicit `import type` for type-only imports
}
}
module: "ESNext"— emitimport/export(modern). With a bundler, this is right.module: "CommonJS"— emitrequire. For Node-only builds without a bundler.module: "NodeNext"— detects ESM vs CJS based on file extension +package.jsontype.moduleResolution: "Bundler"(TS 5.0+) — what Vite/Webpack use; allows extension-less imports.
For libraries published to npm: use tsup, unbuild, or tsc with multiple configs to emit both ESM and CJS.
Q: Library publishing — minimum viable package.json.
A:
{
"name": "my-lib",
"version": "1.0.0",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"files": ["dist"],
"sideEffects": false,
"peerDependencies": { "react": ">=18" }
}
Use a build tool like tsup (esbuild-based, configures dual emit automatically) for libraries.
Gotchas / edge cases
__dirname/__filenamedon’t exist in ESM. Useimport.meta.url+fileURLToPath.- JSON imports —
import data from "./data.json" with { type: "json" }(Node 22+ with import attributes). Older: dynamic import or read withfs. - CSS imports as side effects — declare in
sideEffectsarray, not asfalse, so bundlers don’t tree-shake them away. exportsfield is opt-in — without it, Node falls back tomain. But once you add it, only the listed paths are importable (encapsulation kicks in).- Conditional exports order matters —
typesshould come first in each branch so TS sees them beforeimport/require. require("esm-pkg")works in Node 22+ with--experimental-require-module— was historically a fatal error.
What a senior is expected to say
- “Ship ESM by default for new code; use
exportsfor dual emit if you need CJS compatibility. UMD is legacy.” - “The dual-package hazard means singletons break across instances — keep stateful singletons out, or ensure consumers all resolve the same way.”
- “
exportsfield is the authoritative entry-point map; conditional resolution (importvsrequire) lets one package serve both worlds. Subpath exports encapsulate the public API.” - “
type: \"module\"flips.jsto ESM..cjsfor explicit CJS exceptions;.mjsfor explicit ESM if you can’t settype.” - “TS’s
module: \"NodeNext\"+moduleResolution: \"NodeNext\"is for Node-native publishing;module: \"ESNext\"+moduleResolution: \"Bundler\"for bundled apps.”
Cross-references
- Tree shaking depends on ESM: ../15_performance/04_tree_shaking.md
- Bundler module support: 01_vite_vs_webpack_vs_turbopack.md
- Package managers and lockfiles: 07_package_managers.md
- TypeScript module settings: ../04_typescript/
Further reading
- Node.js — Modules: ECMAScript modules: https://nodejs.org/api/esm.html
- Node.js —
package.jsonexports: https://nodejs.org/api/packages.html#exports - “Dual CJS/ESM packages” — Andrea Giammarchi: https://2ality.com/2022/10/dual-package-hazard.html
- tsup: https://tsup.egoist.dev/