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.
Condition Matching Is First-Match-Wins
The exports map is not a lookup table. Node.js walks the conditions of an object in the order they are written, and takes the first key whose condition is active in the current environment. That single rule explains nearly every “why is my package resolving to the wrong file?” report, because it means the object’s key order is semantically significant in a format — JSON — where readers instinctively assume it is not.
Two orderings are therefore effectively mandatory. "types" goes first, because TypeScript stops at the first matching condition just as the runtime does, and a types key placed after import is never consulted for an ESM consumer. "default" goes last, because it matches unconditionally — any condition written below it is dead configuration.
Beyond those two, the ordering that matters is between environment conditions. "browser", "node", "worker", and "deno" are all runtime conditions, and a bundler targeting the browser will typically activate both browser and import. Writing browser above import gives the browser build priority; writing it below means a browser bundler that also requests import gets the generic ESM file instead of your browser-specific one. Neither is wrong — but only one matches your intent, and the JSON gives no hint which you chose deliberately.
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"browser": "./dist/index.browser.mjs",
"node": {
"import": "./dist/index.node.mjs",
"require": "./dist/index.node.cjs"
},
"default": "./dist/index.mjs"
}
}
}
Nested condition objects, as in the node key above, are resolved the same way: the outer key must match first, then the inner walk begins. This is how a package expresses “for Node.js specifically, split by module system; for everything else, one file”.
Subpath Patterns and Encapsulation
The second job of exports — after routing conditions — is encapsulation. Before the field existed, every file inside a published package was reachable: require("my-lib/dist/internal/secret-helper") worked, and consumers depended on it. Adding an exports map closes the package: only the subpaths you list are importable, and everything else raises ERR_PACKAGE_PATH_NOT_EXPORTED.
That is a feature, and it is also the most common source of upgrade complaints, because adding exports to a package that previously had none is a breaking change for anyone doing a deep import. Two mitigations make the transition survivable. The first is a pattern export, which opens a whole directory in a controlled way:
{
"exports": {
".": "./dist/index.mjs",
"./plugins/*": "./dist/plugins/*.mjs",
"./package.json": "./package.json"
}
}
The * is a literal substitution, not a glob: my-lib/plugins/auth maps to ./dist/plugins/auth.mjs. It matches at any depth, so plugins/oauth/github maps to ./dist/plugins/oauth/github.mjs — which is usually what you want, but is worth knowing before you assume a single path segment.
The ./package.json entry deserves its own mention. A surprising number of tools — bundler plugins, framework build steps, test runners — read a dependency’s manifest at runtime, and an exports map that omits it breaks them with an error that names your package but originates in someone else’s tooling. Exporting it costs nothing and prevents an entire class of confusing bug report.
The second mitigation is a deprecation window. Keep the deep paths working for one major version by listing them explicitly, mark them as deprecated in the changelog, and only then remove them:
{
"exports": {
".": "./dist/index.mjs",
"./utils": "./dist/utils.mjs",
"./dist/utils.js": "./dist/utils.mjs"
}
}
The third entry is deliberately ugly: it maps the old internal path a consumer might have imported to the new public one, so existing code keeps working while the changelog tells people to move to my-lib/utils. Delete it in the next major.
Deciding which deep paths deserve that treatment is easier than it sounds. Public code search and your own issue tracker will surface the handful of internal paths people actually import, and in practice it is a short list dominated by two shapes: a utility module that should always have been exported, and a type-only file that consumers reached for because the root export did not re-export a type they needed. Both are signals about your public surface rather than merely migration debt — the first should become a permanent subpath, the second should be fixed by exporting the type from the entry point.
HAZARD PREVENTION
Symptom: After adding an
exportsmap, a consumer reportsERR_PACKAGE_PATH_NOT_EXPORTEDfor a path that appears in your documentation, and the file plainly exists in the tarball.Root cause: The map lists the source path rather than the published one, or lists a directory (
"./utils/") rather than a pattern ("./utils/*"). Trailing-slash directory exports were removed from Node.js and are silently unusable in current versions.Fix: Replace every trailing-slash entry with an explicit
*pattern, and validate the whole map against the packed tarball withpublint, which resolves each entry against the real file list rather than the working directory.
Anatomy of One Exports Entry
Reading an exports map is easier once the three nested levels have names. Every entry is a subpath, whose value is either a file or a condition object, whose values are in turn files or further condition objects.
Each level has its own validator: the subpath level is checked by trying the import, the condition level by attw, and the target level by publint against a packed tarball.
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. -
Fixing ERR_PACKAGE_PATH_NOT_EXPORTED — diagnosing the four causes of the not-exported error and fixing each one.
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