Hermes Wiki
Developer/Languages/TypeScript/PackagingAndPublishing/Fundamentals/dual-esm-cjs-packaging-and-the-exports-field

Dual ESM/CJS Packaging and the package.json exports Field

Concept

Node.js has two module systems that don't share a loading algorithm: CommonJS (require(), synchronous, resolves .js/.cjs files with module.exports) and ECMAScript Modules (import, potentially async at the loader level, resolves .mjs/.js-with-"type":"module" files with static export). A published npm package's consumers are split across both — an older Express app still on require(), a modern Next.js app using import — and a library that wants to serve both without forcing a migration has to ship both builds and tell Node which one to hand out.

The "exports" field in package.json is how that dispatch happens. It replaces the older convention of just pointing "main" at a single CJS entry file (which ESM consumers could still import via Node's CJS-interop, but with rough edges around named exports and no ability to serve a genuinely different file). "exports" supports conditional exports — a map keyed by condition name, resolved in the order the conditions are listed, first match wins:

{
  "type": "module",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs"
    }
  }
}

"import" matches when the consumer does import/import(); "require" matches require(); the two are mutually exclusive by Node's resolution rules, so a single load never satisfies both. TypeScript adds its own condition, "types", which must be listed before "import"/"require" in each conditions block — TypeScript's resolver reads exports top-to-bottom and needs to see "types" first regardless of runtime condition order, a quirk that trips up hand-written exports maps. Since TS 4.7, type declaration files can also be named .d.mts/.d.cts to pair explicitly with .mjs/.cjs outputs, because a single ambient .d.ts can't correctly describe two entry points whose runtime semantics (default-export interop, __esModule marker) genuinely differ.

Tradeoffs

Approach Benefit Cost
ESM-only ("type": "module", single import condition) Simplest build, one output, matches where the ecosystem is heading, no dual-package hazard possible Locks out any consumer still on plain require() — a real cost for widely-depended-on libraries (many popular packages held off ESM-only for years for exactly this reason)
CJS-only ("main", no "exports" conditions) Works everywhere, zero build complexity, easiest for consumers on older tooling ESM consumers pay Node's CJS-interop cost (default-export-only in strict cases, no true tree-shaking, occasional named-export detection failures on non-static shapes)
Dual package (import/require conditions, two builds) Serves both consumer bases without forcing a migration Two builds to maintain and test; risk of the dual package hazard — if the same package gets loaded once via require() and once via import in the same process, Node treats them as two separate module instances with separate module-level state, so a singleton (a cache, a registered plugin list, an instanceof check against a class from that module) silently diverges between the two loads

The dual package hazard is the sharpest edge here: it's not a build error, it's a runtime bug that only appears when both load paths actually get exercised in the same process (e.g. a CJS-only dependency deep in the tree pulling in the CJS build while the app's own code pulls in the ESM build). Node's own package-examples guidance is to avoid stateful singletons in a dual-published package's public surface, or to accept CJS-only/ESM-only for packages where shared state is central to the design.

When to use / when not to

  • Ship ESM-only for new libraries with no large legacy consumer base — it's the simpler build and avoids the hazard entirely; this has become the majority recommendation among library maintainers as of the 2026 ecosystem.
  • Ship dual (CJS + ESM) only when a real, sizeable consumer base is still on require() and the package can be built statelessly enough that dual-instantiation is harmless (pure functions, no module-level singletons or class-identity checks across the public API).
  • Don't hand-roll a build for this — use tsup, tshy, or unbuild, which generate correct dual output (including the .d.mts/.d.cts pairing and condition ordering) from a single TypeScript source tree; hand-written dual exports maps are a frequent source of the "types" ordering bug above.
  • Don't rely on "main" alone for a new package — omitting "exports" entirely leaves resolution to Node's older, looser algorithm and forfeits the ability to restrict which subpaths are importable (encapsulation "exports" also provides, beyond just conditions).

Common pitfall

Publishing a dual package where the CJS and ESM builds subtly diverge — most often because the ESM build was added later via a bolt-on transpile step (e.g. running the CJS output through a converter) rather than compiling from the same source with the same TypeScript settings, so edge cases in default-export interop or enum emission differ between the two. The fix is compiling both outputs from one source with one toolchain (a bundler purpose-built for dual output) rather than treating the second format as a derived artifact of the first.

Engineering Lens

The interesting decision here isn't the mechanics of the exports field — it's deciding whether a package needs to support both module systems at all, and that decision has an expiry date. Supporting CJS was close to mandatory for any widely-used package as recently as a few years ago; today, a growing share of maintainers ship ESM-only and treat the remaining CJS consumer base as an acceptable adoption cost, because the dual-package hazard is a real, hard-to-debug tax on every release. The judgment call is closer to a deprecation-policy question than a build-tooling one: what's the actual consumer distribution for this package, and is the singleton-safety cost of going dual worth avoiding versus the adoption cost of going ESM-only.

Sources

Hermes Wiki