Mastering the package.json Exports Field
Master the package.json exports field: conditional exports, TypeScript type mapping, environment-specific routing, and publint validation before npm publishing.
Without a correctly configured exports field, Node.js 12+ and modern bundlers ignore your main and module fields entirely and throw ERR_PACKAGE_PATH_NOT_EXPORTED the moment a consumer tries to import a sub-path. Get the condition order or path prefix wrong and you silently ship the wrong format — CJS where ESM was expected — breaking tree-shaking and triggering the dual-package hazard that duplicates singleton state across a dependency graph.
Prerequisites
Before working through the steps below, confirm:
Canonical Configuration Block
The snippet below is a complete, annotated export map for a dual-format package. Every condition key appears in the order Node.js evaluates them — top to bottom, stopping at the first match.
{
"name": "@acme/sdk",
"version": "2.0.0",
// Do NOT declare "main" or "module" alongside exports in Node 14+;
// they become dead fallback only for very old bundlers.
"exports": {
// The dot (".") key is the default entry — what "import '@acme/sdk'" resolves to.
".": {
// TypeScript MUST come first; tsc resolves declarations before runtime paths.
"types": "./dist/esm/index.d.ts",
// "import" matches native ESM consumers and bundlers that set type:"module".
"import": "./dist/esm/index.mjs",
// "require" matches CommonJS consumers (require(), ts-node default, Jest).
"require": "./dist/cjs/index.cjs",
// "default" is the final safety net — always include it.
"default": "./dist/esm/index.mjs"
},
// Named sub-paths must be explicitly listed; no wildcard globs by default.
"./utils": {
"types": "./dist/esm/utils.d.ts",
"import": "./dist/esm/utils.mjs",
"require": "./dist/cjs/utils.cjs",
"default": "./dist/esm/utils.mjs"
}
}
}
Understanding how ESM and CJS module formats differ at the syntax level is essential before reading the condition keys above — the import and require conditions map directly to the two format boundaries.
Resolution Order Diagram
The diagram below shows how Node.js walks an export map for a single entry point. Condition keys are tested top-to-bottom; the first match wins.
Step-by-Step Implementation
Step 1 — Replace main with a dot export
Legacy main points to a single file with no format discrimination. Replace it:
- "main": "./dist/index.js",
- "module": "./dist/index.esm.js",
+ "exports": {
+ ".": {
+ "types": "./dist/esm/index.d.ts",
+ "import": "./dist/esm/index.mjs",
+ "require": "./dist/cjs/index.cjs",
+ "default": "./dist/esm/index.mjs"
+ }
+ }
Expected result: node -e "require('@acme/sdk')" loads ./dist/cjs/index.cjs; node --input-type=module -e "import '@acme/sdk'" loads ./dist/esm/index.mjs.
HAZARD PREVENTION: Omitting the
defaultfallback Error: Bundlers such as webpack 4 and older Rollup versions that do not understandimport/requireconditions fall through without a match and throw a resolution error. Fix: Always include"default": "./dist/esm/index.mjs"as the final condition in every branch. It acts as a safety net for any tooling that predates conditional export support.
Step 2 — Add named sub-path exports
Without explicit sub-path entries, any import like import { debounce } from '@acme/sdk/utils' throws ERR_PACKAGE_PATH_NOT_EXPORTED regardless of whether the file exists on disk.
{
"exports": {
".": {
"types": "./dist/esm/index.d.ts",
"import": "./dist/esm/index.mjs",
"require": "./dist/cjs/index.cjs",
"default": "./dist/esm/index.mjs"
},
"./utils": {
"types": "./dist/esm/utils.d.ts",
"import": "./dist/esm/utils.mjs",
"require": "./dist/cjs/utils.cjs",
"default": "./dist/esm/utils.mjs"
},
"./package.json": "./package.json"
}
}
Exporting ./package.json explicitly is required by some tooling (e.g. pkg-pr-new, certain bundler plugins) that reads the manifest via the package name.
HAZARD PREVENTION: Path prefix typos Error:
Error: Cannot find module '@acme/sdk/util'— a consumer used the wrong sub-path name. Root cause: The exports map is an exact-string lookup; there is no fuzzy matching or extension inference. Fix: Always prefix every path value with./(package-relative). Validate that every./dist/…file listed actually exists after your build step by runningpublint(see Tooling Validation below).
Step 3 — Map TypeScript declaration files
TypeScript 4.7+ with moduleResolution: "node16" or "bundler" reads exports to find .d.ts files. The types condition must come first in each branch; placing it after import causes tsc to skip it.
{
"exports": {
".": {
"types": "./dist/esm/index.d.ts",
"import": "./dist/esm/index.mjs",
"require": "./dist/cjs/index.cjs",
"default": "./dist/esm/index.mjs"
}
}
}
For packages that must serve distinct declaration syntax in each format (.d.mts for ESM, .d.cts for CJS), use a nested types object:
{
"exports": {
".": {
"types": {
"import": "./dist/esm/index.d.mts",
"require": "./dist/cjs/index.d.cts"
},
"import": "./dist/esm/index.mjs",
"require": "./dist/cjs/index.cjs",
"default": "./dist/esm/index.mjs"
}
}
}
Pair this with the following tsconfig.json options so local development picks up the correct declarations without a full npm install round-trip:
{
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"moduleResolution": "bundler",
"paths": {
"@acme/sdk": ["./dist/esm/index.d.mts"],
"@acme/sdk/*": ["./dist/esm/*"]
}
}
}
HAZARD PREVENTION: Wrong
moduleResolutionin the consumer Error: TypeScript reportsModule '@acme/sdk' has no exported member 'Foo'despite the declaration file being present. Root cause:moduleResolution: "node"(the pre-4.7 default) does not readexportsat all; it falls back to the baretypestop-level field. Fix: Set"moduleResolution": "node16"or"bundler"in the consumer’stsconfig.json, or add a top-level"types": "./dist/esm/index.d.ts"field as a fallback for older toolchains.
Step 4 — Add environment-specific conditions
Use browser and node conditions to route consumers to platform-optimised builds. Custom condition keys such as development and production require explicit activation via bundler config or Node’s --conditions flag — they are not applied automatically.
{
"exports": {
".": {
"types": "./dist/esm/index.d.ts",
"browser": {
"import": "./dist/browser/index.mjs",
"require": "./dist/browser/index.cjs"
},
"node": {
"import": "./dist/node/index.mjs",
"require": "./dist/node/index.cjs"
},
"default": "./dist/browser/index.mjs"
}
}
}
For environment-specific routing between development and production bundles, see Conditional Exports for Development vs Production, which covers development/production condition keys, Vite’s resolve.conditions, and webpack’s resolve.conditionNames.
Activate custom conditions at the CLI:
# Node.js
node --conditions=development app.mjs
# Vite — vite.config.ts
resolve: { conditions: ['development', 'browser', 'module', 'import', 'default'] }
# webpack — webpack.config.js
resolve: { conditionNames: ['development', 'browser', 'import', 'require', 'default'] }
HAZARD PREVENTION: Missing
browserfield fallback Error: Bundlers that do not negotiate thebrowsercondition (older webpack, unbundled scripts) resolve thenodebuild and ship Node.js-specific APIs to the browser. Fix: Place"default": "./dist/browser/index.mjs"after all platform conditions. Never rely on abrowsercondition alone without adefaultsafety net.
Tooling Validation
Run these commands after every build, and enforce them in CI before npm publish.
# Static analysis — checks file existence, condition ordering, path format
npx publint --strict
# TypeScript resolution across ESM, CJS, and bundler consumer scenarios
npx attw --pack .
# Zero-install smoke test — tsc type-checks against the published artefacts
npx tsc --noEmit --moduleResolution bundler \
--traceResolution 2>&1 | grep "@acme/sdk"
Sample publint pass output:
✓ exports["."]["types"] resolves to ./dist/esm/index.d.ts
✓ exports["."]["import"] resolves to ./dist/esm/index.mjs
✓ exports["."]["require"] resolves to ./dist/cjs/index.cjs
✓ No issues found
Sample attw --pack . output:
┌─────────────────────────────────────────────────────────────────┐
│ @acme/sdk │
├─────────────────┬────────────────────┬──────────────────────────┤
│ │ "moduleResolution" │ File │
│ Resolution Mode │ node16 │ │
├─────────────────┼────────────────────┼──────────────────────────┤
│ require │ ✓ (CJS) │ dist/cjs/index.cjs │
│ import │ ✓ (ESM) │ dist/esm/index.mjs │
│ bundler │ ✓ (ESM) │ dist/esm/index.mjs │
└─────────────────┴────────────────────┴──────────────────────────┘
GitHub Actions pipeline
name: Validate Package Exports
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
validate-exports:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run build
- run: npx publint --strict
- run: npx attw --pack . --ignore-rules=cjs-resolves-to-esm
- name: Smoke test isolated consumer
run: |
# Install from a local tarball — replicates real npm install
npm pack --quiet
mkdir /tmp/test-consumer && cd /tmp/test-consumer
npm init -y
npm install /home/runner/work/my-repo/my-repo/acme-sdk-*.tgz
node --input-type=module -e "import('@acme/sdk').then(m => console.log(Object.keys(m)))"
node -e "const m = require('@acme/sdk'); console.log(Object.keys(m))"
HAZARD PREVENTION: Validating inside the source workspace Error:
publintreports no issues, but consumers seeERR_PACKAGE_PATH_NOT_EXPORTEDin production. Root cause:node_modulessymlinks in the source workspace short-circuit normal resolution; the workspace install does not replicate what an external consumer encounters. Fix: Always smoke-test by installing the.tgzproduced bynpm packinto a fresh, isolated directory as shown above.
Compatibility Matrix
| Environment | Conditional exports | browser condition |
types condition |
Custom conditions |
|---|---|---|---|---|
| Node.js 12.7 | Partial (no require) |
No | No | No |
| Node.js 14.x | Yes | No | No | Yes (--conditions) |
| Node.js 16.x | Yes | No | No | Yes |
| Node.js 18+ / 20+ | Yes | No | No | Yes |
| webpack 4 | main/module only |
Yes (via resolve.mainFields) |
No | No |
| webpack 5+ | Yes | Yes | No | Yes (resolve.conditionNames) |
| Rollup 2 | Partial | Yes | No | No |
| Rollup 3+ | Yes | Yes | No | Yes (output.generatedCode) |
| Vite 3+ | Yes | Yes | No | Yes (resolve.conditions) |
| esbuild 0.14+ | Yes | Yes | No | Yes (--conditions) |
TypeScript 4.7+ node16 |
Yes | No | Yes | No |
TypeScript 5+ bundler |
Yes | No | Yes | No |
Note: the browser condition is a bundler convention, not a Node.js standard. Node.js never activates it automatically; only bundlers that set it in their conditionNames default list will resolve it. For the full picture of how resolution differs between environments, see Browser vs Node.js Module Resolution.
Guides in This Section
- Conditional Exports for Development vs Production — how to wire
developmentandproductioncondition keys so bundlers and Node.js each receive the correct build without code changes.
Related
- Navigating the Dual-Package Hazard — understand why shipping both ESM and CJS from the same package risks singleton state duplication, and the architectural safeguards that prevent it.
- Understanding ESM vs CJS Module Formats — the syntax and runtime differences between
import/exportandrequire/module.exportsthat the exports field condition keys map onto. - Browser vs Node.js Module Resolution — how resolution algorithms differ between runtimes and why the same
exportsmap can resolve to different files depending on who is importing. - Eliminating Barrel File Anti-Patterns — barrel re-exports interact badly with sub-path exports and defeat tree-shaking; read this alongside your exports map work.
- Implementing the
sideEffectsFlag Correctly — pair a clean exports map with a correctsideEffectsdeclaration to unlock full dead-code elimination in bundlers.
Back to Module System Fundamentals & Dual-Package Resolution