Mastering the package.json Exports Field
Master the package.json exports field with conditional exports, TypeScript type mapping, environment-specific routing, and automated publint validation before npm publishing.
The package.json exports field has replaced legacy main and module properties as the authoritative contract for module resolution in modern JavaScript ecosystems. For library maintainers and platform engineers, correctly configuring this field dictates how consumers resolve entry points, how bundlers optimize tree-shaking, and how TypeScript enforces type safety. Understanding the underlying resolution mechanics is critical to avoiding runtime fragmentation. For foundational architecture governing modern package distribution, consult Module System Fundamentals & Dual-Package Resolution before implementing advanced export maps.
Core Syntax and Resolution Priority
The exports object defines explicit entry points and establishes a strict resolution order. Node.js evaluates conditions top-to-bottom, stopping at the first match. This deterministic behavior replaces the ambiguous fallback logic of older package schemas.
Resolution Priority & Condition Matching
When a consumer imports your package, Node.js inspects the runtime environment and matches against the following priority chain:
import(ESM consumers)require(CommonJS consumers)default(Universal fallback)
You can use string shorthand for single entry points or object syntax for conditional routing.
{
"name": "@acme/sdk",
"version": "2.0.0",
"exports": {
".": {
"import": "./dist/esm/index.mjs",
"require": "./dist/cjs/index.cjs",
"default": "./dist/esm/index.mjs"
},
"./utils": "./dist/esm/utils.mjs"
}
}
️ HAZARD PREVENTION: Omitting Fallbacks Issue: Omitting a fallback or default export breaks compatibility with legacy bundlers and older Node.js versions that do not recognize conditional keys. Fix: Always declare a top-level string or
"default"condition at the end of each export map. This guarantees graceful degradation and preventsERR_PACKAGE_PATH_NOT_EXPORTEDfailures during resolution.
Conditional Exports for Environment-Specific Builds
Modern applications run across diverse environments. The exports field supports custom condition keys that allow you to route consumers to optimized bundles based on runtime, bundler, or build mode.
Browser vs Node.js & Custom Conditions
You can explicitly target environments using browser, node, or bundler-specific keys like webpack and vite. These conditions are negotiated at build time or runtime depending on the consumer’s configuration.
{
"exports": {
".": {
"browser": "./dist/browser/index.mjs",
"node": {
"import": "./dist/node/esm/index.mjs",
"require": "./dist/node/cjs/index.cjs"
},
"default": "./dist/browser/index.mjs"
}
}
}
️ HAZARD PREVENTION: Path Resolution in Nested Dependencies Issue: Hardcoding relative paths that break in nested
node_modulesstructures. Fix: Always prefix paths with./to enforce package-relative resolution. Never use absolute filesystem paths or omit the leading slash. Validate paths with automated resolution tools to prevent silent resolution failures.
CLI Condition Negotiation
Bundlers and Node.js allow custom condition injection via CLI flags. This enables environment-specific routing without modifying source code.
# Node.js custom condition resolution
node --conditions=development --conditions=custom-runtime app.js
# Vite custom condition override
vite build --mode staging
When routing consumers to format-specific entry points, understanding how the exports field handles syntax divergence is essential. Refer to Understanding ESM vs CJS Module Formats for format-specific resolution rules. Strict export mapping also mitigates state duplication across module boundaries; see Navigating the Dual-Package Hazard for architectural safeguards. For detailed strategies on environment-specific routing, consult Conditional Exports for Development vs Production.
TypeScript Declaration Mapping and Type Safety
Exposing .d.ts files alongside JavaScript outputs requires precise condition ordering. TypeScript’s resolution algorithm prioritizes the types condition before evaluating runtime code paths. Misordering this condition causes type resolution failures or forces TypeScript to fall back to incorrect declaration files.
Correct Condition Placement & Co-Location
Always place types at the top of each export branch. Co-locate declarations with their corresponding JS outputs to maintain a 1:1 mapping.
{
"exports": {
".": {
"types": "./dist/esm/index.d.ts",
"import": "./dist/esm/index.mjs",
"require": "./dist/cjs/index.cjs",
"default": "./dist/esm/index.mjs"
},
"./internal": {
"types": "./dist/esm/internal.d.ts",
"import": "./dist/esm/internal.mjs",
"default": "./dist/esm/internal.mjs"
}
}
}
Dual-Type Declarations for ESM/CJS
When publishing dual-format packages, you must emit separate declaration files for ESM and CJS to preserve import/export syntax accuracy in TypeScript consumers.
{
"exports": {
".": {
"types": {
"import": "./dist/esm/index.d.mts",
"require": "./dist/cjs/index.d.cts"
},
"import": "./dist/esm/index.mjs",
"require": "./dist/cjs/index.cjs"
}
}
}
️ HAZARD PREVENTION: Condition Ordering Issue: Misplacing the
typescondition aboveimportorrequire. Fix: Always declare thetypescondition first in the exports object. TypeScript resolves the declaration before evaluating runtime code paths. Placing it lower causes type-checking to fail or fall back to implicit@typesresolution.
Complement this configuration with tsconfig.json path mapping for local development:
{
"compilerOptions": {
"declaration": true,
"declarationDir": "./dist",
"moduleResolution": "bundler",
"paths": {
"@acme/sdk": ["./dist/esm/index.d.mts"],
"@acme/sdk/*": ["./dist/esm/*"]
}
}
}
CI/CD Validation and Automated Export Testing
Export maps are fragile. A single typo in a condition key or path breaks consumer builds. Platform engineers must enforce automated validation before publishing to npm registries.
Pre-Publish Linting & Type Verification
Integrate publint and are-the-types-wrong (attw) into your CI pipeline. These tools statically analyze your package.json exports, verify file existence, and validate TypeScript resolution across multiple consumer configurations.
# Install validation tooling
npm install -D publint are-the-types-wrong
# Run static analysis
npx publint
npx attw --pack .
GitHub Actions Pipeline Configuration
Automate smoke testing in isolated consumer sandboxes to catch resolution failures before they reach production.
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
- name: Install dependencies
run: npm ci
- name: Build package
run: npm run build
- name: Run publint
run: npx publint --strict
- name: Validate TypeScript exports
run: npx attw --pack . --ignore-rules=cjs-resolves-to-esm
- name: Smoke test isolated consumer
run: |
mkdir -p test-consumer
cd test-consumer
npm init -y
npm install ../
node -e "import('@acme/sdk').then(console.log)"
️ HAZARD PREVENTION: Pipeline Isolation Issue: Running validation in the same workspace as the source package masks resolution failures due to symlinked dependencies. Fix: Always execute consumer smoke tests in a temporary directory or isolated Docker container. Use
npm packto generate a.tgzarchive and install it as a standard dependency to replicate real-world consumption.
By enforcing strict export mapping, prioritizing type declarations, and automating resolution validation, library maintainers eliminate consumer friction and ensure predictable module resolution across all JavaScript runtimes and bundlers.