Frontend / Build tools / 03_module_formats.md

Module Formats — ESM vs CJS vs UMD, the Dual-Package Hazard

Updated 5 interview angles 6 min read source
On this page13
  1. Show me each format syntactically.
  2. Why does ESM matter?
  3. How does package.json express both formats?
  4. What’s the dual-package hazard?
  5. Can you import a CJS package from ESM?
  6. Can you require an ESM package from CJS?
  7. What’s "type": "module" in package.json?
  8. TypeScript module emission — what controls it?
  9. Library publishing — minimum viable package.json.
  10. Gotchas
  11. Related
  12. Further reading
  13. Interview angle

Module Formats — ESM vs CJS vs UMD, the Dual-Package Hazard

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.

Show me each format syntactically.

js
// 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.

Why does ESM matter?

  • 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 await at 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).

How does package.json express both formats?

The exports field. Modern, conditional, explicit:

json
{
  "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 .js files as ESM by default.
  • exports is the authoritative entry-point map; legacy main/module are fallbacks.
  • Conditional resolution — import for ESM consumers, require for CJS.
  • Subpath exports — import { Button } from "mylib/button" resolves to ./dist/button.mjs for ESM consumers.
  • ./package.json explicit 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.

What’s the dual-package hazard?

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 exports to 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.

Can you import a CJS package from ESM?

Yes, but named imports may not work cleanly.

js
// 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:

js
import pkg from "cjs-pkg";
const { foo } = pkg;

Bundlers usually handle this with interop wrappers; standalone Node varies.

Can you require an ESM package from CJS?

Not synchronously. ESM is async-resolved; require() is sync. Options:

js
// 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.

What’s "type": "module" in package.json?

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 nearest package.json’s type.

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.

TypeScript module emission — what controls it?

tsconfig.json:

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" — emit import/export (modern). With a bundler, this is right.
  • module: "CommonJS" — emit require. For Node-only builds without a bundler.
  • module: "NodeNext" — detects ESM vs CJS based on file extension + package.json type.
  • 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.

Library publishing — minimum viable package.json.

json
{
  "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

  • __dirname / __filename don’t exist in ESM. Use import.meta.url + fileURLToPath.
  • JSON imports — import data from "./data.json" with { type: "json" } (Node 22+ with import attributes). Older: dynamic import or read with fs.
  • CSS imports as side effects — declare in sideEffects array, not as false, so bundlers don’t tree-shake them away.
  • exports field is opt-in — without it, Node falls back to main. But once you add it, only the listed paths are importable (encapsulation kicks in).
  • Conditional exports order matters — types should come first in each branch so TS sees them before import/require.
  • require("esm-pkg") works in Node 22+ with --experimental-require-module — was historically a fatal error.

Further reading

Interview angle 5

  • “What should a new package ship?” - ESM, with an exports map. Add a CJS build through conditional exports only if consumers genuinely need it. UMD is legacy and there is essentially no reason to emit it for new work.
  • “What is the dual-package hazard?” - a package loaded as both ESM and CJS in one process, giving two separate module instances. Any singleton - a registry, a context, a connection pool - now exists twice, and the symptom is “my provider is not found” with everything looking correct.
    • Follow-up: “How do you avoid it?” - keep stateful singletons out of dual-published packages, or publish ESM-only so there is only one instance to have.
  • “What does the exports field actually do?” - it is the authoritative entry-point map, and it encapsulates the package: anything not listed cannot be imported, so deep imports into internals stop working. Conditional keys let one package serve import and require differently, and subpaths define the real public API.
  • “How does Node decide if a .js file is ESM?” - "type": "module" in the nearest package.json. Beyond that, .mjs forces ESM and .cjs forces CommonJS regardless of the field, which is how you keep one exception in an otherwise-uniform package.
  • “Which TypeScript module settings?” - NodeNext for both module and moduleResolution when publishing for Node, so TypeScript enforces the real resolution rules including file extensions. ESNext with moduleResolution: "Bundler" for an app where a bundler resolves everything.