frontend / build tools / 03_module_formats.md

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

6 min read source

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 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).

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 .js files as ESM by default.
  • exports is the authoritative entry-point map; legacy main/module are fallbacks.
  • Conditional resolutionimport for ESM consumers, require for CJS.
  • Subpath exportsimport { 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.

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

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

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" — 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.

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 / __filename don’t exist in ESM. Use import.meta.url + fileURLToPath.
  • JSON importsimport 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 matterstypes 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.

What a senior is expected to say

  • “Ship ESM by default for new code; use exports for 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.”
  • exports field is the authoritative entry-point map; conditional resolution (import vs require) lets one package serve both worlds. Subpath exports encapsulate the public API.”
  • type: \"module\" flips .js to ESM. .cjs for explicit CJS exceptions; .mjs for explicit ESM if you can’t set type.”
  • “TS’s module: \"NodeNext\" + moduleResolution: \"NodeNext\" is for Node-native publishing; module: \"ESNext\" + moduleResolution: \"Bundler\" for bundled apps.”

Cross-references

Further reading