Module System Fundamentals & Dual-Package Resolution
Master ESM vs CJS execution models, the dual-package hazard, and the package.json exports field for safe, tree-shakeable dual-format npm package distribution.
This guide covers the two JavaScript module execution models — CommonJS and ECMAScript Modules — their resolution mechanics, the failure modes that arise when both coexist, and the package.json configuration required to publish safely to both. It is written for library authors and platform engineers who distribute npm packages and need deterministic behavior across Node.js versions, bundlers, and TypeScript consumers.
Quick-Reference: Key Terms
| Term | Definition | Reference |
|---|---|---|
| CommonJS (CJS) | Synchronous require()-based module system; default in Node.js without "type": "module" |
ESM vs CJS formats |
| ECMAScript Modules (ESM) | Static import/export syntax; enables tree-shaking and asynchronous loading |
ESM vs CJS formats |
| Dual-package hazard | Two separate module instances when the same package is loaded via both require() and import |
Navigating the dual-package hazard |
exports field |
package.json key that replaces main/module with explicit condition-based routing |
Mastering the exports field |
| Conditional exports | Sub-keys inside exports ("import", "require", "browser", "types") that route by environment |
Mastering the exports field |
moduleResolution |
TypeScript compiler option that controls how import paths are resolved (node16, bundler, etc.) |
Browser vs Node.js resolution |
| Tree-shaking | Dead-code elimination performed by bundlers on statically-analyzable ESM graphs | Tree-shaking & bundle optimization |
| Provenance attestation | Cryptographic record linking a published tarball to the exact CI workflow that built it | Publishing workflows |
How Node.js Chooses a Module Format
Node.js determines the module format of every file before executing it. The decision is made once, at load time, and cannot change mid-execution. Two signals drive it:
File extension takes the highest precedence. A .mjs file is always ESM; a .cjs file is always CJS. The .js extension is ambiguous — Node resolves it by walking up the directory tree to find the nearest package.json and reading its "type" field.
The "type" field in package.json applies to every .js file within that package’s scope. "type": "module" makes .js files ESM; omitting it (or writing "type": "commonjs") keeps them CJS.
// package.json — affects all .js files in this directory tree
{
"name": "my-lib",
"type": "module"
}
This matters most during dual-format builds where a single source tree compiles into both .mjs and .cjs outputs: the "type" field governs the .js fallback but .mjs/.cjs extensions override it unconditionally.
The CJS and ESM Caching Models
The two module systems maintain entirely separate internal caches inside the same Node.js process. Understanding the difference explains why shared state breaks across the format boundary.
CJS cache (require.cache): When require('my-lib') is called for the first time, Node.js evaluates the module body synchronously, captures the result in require.cache, and returns it on every subsequent call. The cache key is the resolved absolute file path. Two different require() calls to the same path return the exact same object reference.
ESM cache: ESM maintains its own module registry, keyed by the module URL (including the file:// scheme). The registry is populated during the asynchronous link phase before any code runs. import statements pointing to a .cjs file cause Node to load it through the CJS evaluator and wrap the result — but it still enters the ESM registry as a distinct entry.
// cjs-consumer.cjs
const libA = require('my-lib');
libA.counter = 1;
// esm-consumer.mjs
import libB from 'my-lib';
console.log(libB.counter); // undefined — separate instance
This divergence is the root cause of the dual-package hazard. A singleton Map, event emitter, or global registry lives in the cache of whichever format first loaded it; the other format sees a fresh, empty copy.
Static vs Dynamic Module Analysis
The most consequential architectural difference between the two systems is not syntax — it is when the dependency graph is resolved.
CJS is evaluated dynamically. require() can appear inside if blocks, loops, or factory functions. The resolved module path can be a runtime string. This makes static analysis impossible: a bundler cannot know which exports are used without executing the code first.
ESM is analyzed statically. import and export declarations are resolved during parsing, before any code runs. Module specifiers must be string literals. Bundlers traverse the full import graph at build time and can mark unused exports as dead code — a prerequisite for tree-shaking to work.
// CJS: dynamic — bundler cannot statically trace this
const mod = process.env.DEBUG ? require('./debug') : require('./prod');
// ESM: static — bundler sees both imports at parse time and can eliminate unused ones
import { debug } from './debug.js';
import { prod } from './prod.js';
The practical implication: if your library ships only CJS, consumers using Webpack, Rollup, or Vite cannot reliably eliminate unused exports. Shipping an ESM output alongside CJS is the minimal requirement for enabling dead-code elimination in downstream bundles.
Hazard and Failure-Mode Inventory
ERR_PACKAGE_PATH_NOT_EXPORTED
HAZARD PREVENTION
Symptom:
Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: Package subpath './utils' is not defined by "exports" in .../package.jsonRoot cause: The consuming code imports a path that is not declared in the
exportsmap. Onceexportsis present, Node.js blocks access to any path not explicitly listed — including paths that previously worked via direct file access.Fix: Add the subpath to your
exportsmap, or expose a glob entry:"./utils/*": "./dist/utils/*.js".
Singleton Bifurcation
HAZARD PREVENTION
Symptom: A class instance check fails (
instanceofreturnsfalse), a registry is empty after population, or event listeners registered in one part of the app are never called.Root cause: The package was loaded by both
require()andimportin the same process, creating two isolated instances of the module. Each copy maintains its own state.Fix: Enforce a single entry point by making the CJS wrapper delegate to the ESM build via a wrapper pattern, or ship ESM-only and require Node.js ≥ 12.
ERR_REQUIRE_ESM
HAZARD PREVENTION
Symptom:
Error [ERR_REQUIRE_ESM]: require() of ES Module .../node_modules/my-lib/index.mjs not supported.Root cause: A CJS consumer tried to
require()a package that declares"type": "module"or uses.mjsfiles without providing a"require"condition in itsexportsmap.Fix: Add a
"require": "./dist/index.cjs"condition to theexportsentry, or instruct consumers to upgrade to a Node.js version that supportsrequire()of ESM (Node.js 22+, behind an experimental flag). See fixing require errors in pure ESM packages.
TypeScript Type Drift
HAZARD PREVENTION
Symptom:
Type 'X' is not assignable to type 'X'even though the types look identical. Two packages that import the same library disagree on its types.Root cause: The
typescondition in yourexportsmap is missing or points to a declaration file generated for a different format. TypeScript resolves types separately from runtime values; if the two disagree, the type system sees two incompatible versions of the same type.Fix: Place
"types"first in everyexportscondition block and ensure.d.tsfiles are co-generated with their corresponding.mjs/.cjsartifacts by the same build step.
Condition Ordering Mistake
HAZARD PREVENTION
Symptom: TypeScript resolves the wrong declaration file, or bundlers pick the CJS artifact instead of the ESM one in a browser build.
Root cause: Conditions inside an
exportsentry are evaluated top-to-bottom; the first match wins. Placing"require"before"import"in an environment that matches both will route ESM consumers to the CJS build.Fix: Always order conditions as
types→import→require→default. Full configuration details are in mastering theexportsfield.
Decision Guide: Which Package Format Should You Ship?
The right publishing strategy depends on who your consumers are, which tools they use, and how much backward compatibility you need to maintain.
Practical defaults for 2025 and beyond:
- New libraries with no legacy consumers — ship ESM-only. Set
"type": "module"and use a single"exports"entry with"types"and"import"conditions. - Established libraries with mixed consumer bases — ship dual format. Use a build tool (
tsup,rollup, oresbuild) to emit both.mjsand.cjsoutputs, then route them via conditional exports. - Internal tooling or CLI packages — CJS-only is the path of least resistance if no downstream bundler is involved.
For environment-specific routing (e.g. browser vs. Node.js builds), configure the exports field with nested "browser" and "node" conditions alongside the format conditions.
Configuring the exports Field
The exports field in package.json is the authoritative routing table for your package’s public API. When present, Node.js ignores main, module, and all paths not explicitly declared in the map.
{
"name": "my-lib",
"version": "1.0.0",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs",
"default": "./dist/index.cjs"
},
"./utils": {
"types": "./dist/utils/index.d.ts",
"import": "./dist/utils/index.mjs",
"require": "./dist/utils/index.cjs"
}
}
}
Condition order matters: Node.js and TypeScript evaluate conditions top-to-bottom and stop at the first match. "types" must come first so TypeScript resolvers find declarations before they reach runtime entries. "default" must be last as a catch-all for non-conforming resolvers.
Detailed configuration patterns — including glob subpath exports, conditionNames in Webpack/Vite, and the "browser" condition — are covered in mastering the exports field.
Cross-Environment Resolution Strategies
Module resolution behavior differs substantially between Node.js, modern browsers, and bundler toolchains.
Node.js natively supports the node: protocol (import fs from 'node:fs'), which bypasses node_modules lookups entirely. The "node" condition in exports maps targets Node.js specifically. Node.js 22+ adds experimental support for require() of ESM, reducing the friction of dual-format distribution.
Browsers have no concept of node_modules. They fetch modules over HTTP using full URLs or module map entries. The "browser" condition in an exports map routes browser-targeted bundlers to platform-safe builds that avoid Node.js built-ins.
Bundlers (Webpack, Vite, Rollup, esbuild) read their own conditionNames configuration to decide which conditions apply. Vite enables "browser" by default in client builds; Webpack requires explicit conditionNames in resolve.conditionNames. Mismatched condition sets are a frequent source of build-time surprises.
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"browser": {
"import": "./dist/browser.mjs",
"require": "./dist/browser.cjs"
},
"node": {
"import": "./dist/node.mjs",
"require": "./dist/node.cjs"
},
"default": "./dist/node.cjs"
}
}
}
The full breakdown of resolution differences across runtimes — including how to configure Node.js for native ESM support — is in browser vs Node.js module resolution.
TypeScript Integration and Declaration Files
TypeScript 5+ added two moduleResolution modes that align compiler behavior with modern conditional exports: "node16"/"nodenext" and "bundler". Earlier modes ("node", "classic") ignore the exports field entirely.
The "bundler" mode is appropriate for projects consumed by Vite, esbuild, or other bundlers that handle bare specifiers natively. "node16"/"nodenext" mirrors Node.js’s own resolution algorithm exactly, including the requirement for explicit file extensions on relative imports.
// tsconfig.json for a library targeting Node.js ≥ 16
{
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"declaration": true,
"declarationMap": true,
"declarationDir": "./dist/types",
"outDir": "./dist"
}
}
A mismatch between the moduleResolution used to compile the library and the one used to consume it causes silent type errors: the type checker may resolve a different .d.ts file than the runtime loads. Ensure your build pipeline generates declaration files that are co-located with — and reference — their corresponding .mjs and .cjs artifacts.
For complex declaration configuration including path mapping and tsconfig path aliases, see path mapping and module resolution strategies.
Publishing Workflows and Provenance Security
Modern publishing workflows automate dual-format compilation, artifact validation, and cryptographic verification before the tarball reaches the registry.
A minimal CI pipeline for a dual-format library:
# 1. Build both formats in parallel
npm run build # emits dist/index.mjs, dist/index.cjs, dist/index.d.ts
# 2. Validate the package before publishing
npx publint # checks exports map, condition ordering, file existence
npx attw --pack # checks TypeScript type resolution against published shape
# 3. Preview what will be published
npm pack --dry-run
# 4. Publish with provenance (requires id-token: write in GitHub Actions)
npm publish --provenance --access public
The --provenance flag generates a Sigstore attestation that links the published tarball to the exact CI workflow run, source commit, and build environment. This provenance attestation is stored in the npm registry’s transparency log and can be verified by consumers with npm audit signatures. The full pipeline — from a GitHub Actions release workflow through OIDC trusted publishing to provenance and Sigstore attestation — is covered in CI/CD, publishing & npm provenance.
HAZARD PREVENTION
Symptom:
npm ERR! code ENEEDAUTHor provenance attestation fails silently.Root cause:
npm publish --provenancerequires theid-token: writeGitHub Actions permission and an npm access token withautomationorpublishscope. Missing either permission causes provenance generation to fail, sometimes without a clear error.Fix: Add
permissions: { id-token: write }to your GitHub Actions workflow job, and ensure yourNPM_TOKENsecret uses a Granular Access Token scoped to the specific package.
Topic Index
Understanding ESM vs CJS Module Formats
A deep dive into synchronous CJS evaluation versus static ESM parsing, including how Node.js determines module format from file extensions and "type" field values.
Read guide →
Includes: How to configure Node.js for native ESM support
Navigating the Dual-Package Hazard
Explains exactly why two module caches exist, which runtime scenarios trigger bifurcation, and the wrapper-pattern strategies that eliminate it without sacrificing tree-shaking. Read guide →
Includes: Fixing require() errors in pure ESM packages
Mastering the package.json Exports Field
Complete reference for the exports map: condition ordering, subpath patterns, glob entries, and the "browser"/"node" nested structure. Includes tooling validation steps with publint and attw.
Read guide →
Includes: Conditional exports for development vs production
Browser vs Node.js Module Resolution
Compares resolution algorithms across Node.js, Vite, Webpack, Rollup, and the browser’s native module loader. Covers conditionNames, the browser condition, and how polyfill strategies differ per tool.
Read guide →
Includes: Resolving type:module conflicts in legacy projects
package.json Fields for Distribution
How main, module, exports, types, files, and sideEffects each steer resolution and packaging, their precedence across Node.js, bundlers, and TypeScript, and how to control the published tarball.
Read guide →
Includes: main, module, and exports precedence
Frequently Asked Questions
Can I safely publish both ESM and CJS formats in a single package?
Yes. Use the exports field with explicit "import" and "require" conditions to route consumers to the correct artifact. The key requirement is that both builds are generated from the same source in the same CI step, so their APIs stay in sync.
How does TypeScript 5+ handle dual module resolution?
TypeScript 5+ added "moduleResolution": "bundler" and improved "node16"/"nodenext" support. Both modes respect the exports field and route the "types" condition to declaration files. Earlier "node" mode ignores exports entirely and falls back to main.
Is sigstore provenance required for npm publishing?
Not required, but increasingly expected by enterprise consumers and supply-chain security audits. npm publish --provenance generates a Sigstore-backed attestation that is verifiable without trusting the publisher — it links the tarball to a specific GitHub Actions run and commit hash.
Why is the exports field preferred over main and module?
The exports field enforces strict path resolution (no implicit access to internal files), supports conditional routing per environment, and is the only mechanism Node.js uses for "require" vs "import" disambiguation. The "module" field is a bundler-only convention, not a Node.js standard, and is not respected by the Node.js runtime at all.
What happens if I omit the "default" condition?
Tools that do not recognize any of your declared conditions (older bundlers, custom resolvers) will throw ERR_PACKAGE_PATH_NOT_EXPORTED or silently fail to resolve the package. A "default" fallback pointing to your CJS build ensures backward compatibility.
Related
- Tree-shaking & bundle optimization — how ESM’s static structure enables dead-code elimination in Webpack, Rollup, and esbuild, and how
sideEffectsinpackage.jsonaffects the result. - TypeScript configuration & build tooling —
tsconfig.jsonsettings for library distribution, declaration file generation, and path mapping alongside dual-format builds. - Optimizing tsconfig.json for library distribution — concrete
compilerOptionsfor pairingNodeNextresolution with dual-format output fromtsuporrollup. - Implementing the sideEffects flag correctly — marking packages as side-effect-free to unlock the most aggressive tree-shaking in downstream builds.