package.json Fields for Distribution
The package.json fields that control how npm packages distribute: main, module, exports, types, files, sideEffects, and their precedence across tools.
Every field in package.json that touches distribution — main, module, exports, types, files, and sideEffects — is read by a different resolver at a different point in the pipeline, and no single tool reads all of them the same way. This guide is for library authors who need one authoritative map of which field controls what, in what order tools consult them, and how to configure each one so Node.js, webpack, Vite, and TypeScript all agree on what your package looks like.
Quick-Reference: Key Terms
| Term | Definition | Reference |
|---|---|---|
main |
Legacy CommonJS entry point, read by Node.js when no exports map exists and by bundlers that predate conditional exports. |
Field Precedence |
module |
Non-standard ESM entry point read only by bundlers (webpack, Rollup, Vite), never by Node.js directly. | Field Precedence |
exports |
Structured, conditional map that overrides main/module in Node 12.7+ and modern bundlers. |
Mastering the exports Field |
types |
Top-level declaration file path used by TypeScript’s legacy node resolution mode. |
Field Precedence |
files |
Whitelist of paths included in the published tarball; works alongside .npmignore. |
files Field & npm pack |
sideEffects |
Declares whether a package’s modules can be safely dropped by tree-shaking bundlers when unused. | Implementing the sideEffects Flag |
Core Concepts
The canonical fully-annotated package.json
The snippet below shows every distribution-relevant field in one place, annotated with which tool consults it.
{
"name": "@acme/toolkit",
"version": "3.1.0",
"type": "module",
"main": "./dist/cjs/index.cjs",
"module": "./dist/esm/index.mjs",
"types": "./dist/cjs/index.d.ts",
"exports": {
".": {
"types": "./dist/esm/index.d.ts",
"import": "./dist/esm/index.mjs",
"require": "./dist/cjs/index.cjs",
"default": "./dist/esm/index.mjs"
},
"./package.json": "./package.json"
},
"files": [
"dist",
"!dist/**/*.test.js"
],
"sideEffects": false
}
Each field here serves a distinct audience: main and module cover tools that never learned to read exports; the exports map is the modern, authoritative source for Node.js and current bundlers; types is a fallback for TypeScript’s older resolution mode; files controls the tarball contents independently of any resolution logic; and sideEffects is read exclusively by tree-shaking bundlers, never by Node.js.
Why redundant fields still matter
It would be simpler to declare only exports and drop main, module, and the top-level types. In practice, some consumers still run tooling old enough to ignore exports entirely — Node.js below 12.7, webpack 4, or TypeScript with moduleResolution: "node". Keeping the legacy fields pointed at equivalent artifacts (not stale ones) means those consumers degrade gracefully instead of failing outright. The main, module, and exports field precedence guide covers exactly which field wins when more than one is present.
{
"main": "./dist/cjs/index.cjs",
"module": "./dist/esm/index.mjs",
"types": "./dist/cjs/index.d.ts"
}
If main points at an outdated build while exports points at a current one, consumers on old tooling silently receive stale code — a common source of “it works for me but not for this user” bug reports.
Hazard and Failure-Mode Inventory
HAZARD PREVENTION
Symptom:
npm publishsucceeds, but the installed package is missingdist/entirely and only source files are usable.Root cause: No
filesfield and no.npmignore, so npm defaults to publishing everything not covered by its built-in ignore list — which does not know your build output directory name.Fix: Add an explicit
filesarray listing your build output directory, and verify withnpm pack --dry-runas described in the files field guide.
HAZARD PREVENTION
Symptom: Consumers using webpack report duplicated CSS or lost side effects (a polyfill silently vanishes) after upgrading your package.
Root cause:
"sideEffects": falsewas set on a package that still imports polyfills or CSS at module scope for their side effects, so the bundler drops those imports as unused.Fix: Either keep
sideEffectsan array naming the exact files with side effects, or refactor away from side-effecting module-scope code. See Implementing the sideEffects Flag Correctly.
HAZARD PREVENTION
Symptom: TypeScript reports
Could not find a declaration file for module '@acme/toolkit'even though.d.tsfiles exist in the tarball.Root cause: The top-level
typesfield points at a path that does not exist in the publishedfilesallowlist, or theexportsmap’stypescondition is missing or ordered afterimport/require.Fix: Confirm the declaration path is included by
files, and place"types"first in everyexportsbranch — never afterimportorrequire.
HAZARD PREVENTION
Symptom:
npm installworks butrequire()throwsCannot find module './package.json'when a tool tries to read package metadata at runtime.Root cause: A strict
exportsmap was added without an explicit"./package.json": "./package.json"entry, and Node.js now refuses any path not listed inexports.Fix: Always add the
./package.jsonself-reference alongside your other export entries.
Decision Guide
Choosing which fields to set depends on which consumers you must support. If you only target Node.js 14+ and modern bundlers, exports alone (plus files and sideEffects) is sufficient. If you support older tooling or a wide npm audience, keep main, module, and top-level types as compatible fallbacks pointed at equivalent builds.
Step-by-Step Implementation
Step 1 — Set the legacy entry fields
{
"main": "./dist/cjs/index.cjs",
"module": "./dist/esm/index.mjs"
}
Expected result: any bundler or Node.js version that predates exports still resolves a working entry point.
Step 2 — Add exports
{
"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 12.7+ and modern bundlers ignore main/module and resolve through this map instead.
Step 3 — Declare types
{
"types": "./dist/cjs/index.d.ts"
}
Expected result: TypeScript running moduleResolution: "node" (no exports support) still finds a declaration file.
Step 4 — Restrict files
{
"files": ["dist"]
}
Expected result: npm pack --dry-run lists only files under dist/ plus npm’s always-included files (package.json, README, LICENSE).
Step 5 — Mark sideEffects
{
"sideEffects": false
}
Expected result: webpack and Rollup drop unused exports across your package’s module graph during tree-shaking.
Tooling Validation
# Confirm the tarball contains only what files/exports expect
npm pack --dry-run
# Static analysis of exports paths, condition ordering, and types resolution
npx publint --strict
# Confirm TypeScript resolves types under every consumer mode
npx attw --pack .
Sample publint pass output:
✓ exports["."]["types"] resolves to ./dist/esm/index.d.ts
✓ "files" allowlist matches build output directory
✓ No issues found
Compatibility Matrix
| Field | Node.js 12.7+ | webpack 5+ | Vite 3+ | TypeScript 4.7+ |
|---|---|---|---|---|
main |
Fallback only | Fallback only | Fallback only | Ignored if exports present |
module |
Never read | Fallback (resolve.mainFields) |
Fallback | Never read |
exports |
Authoritative | Authoritative | Authoritative | Authoritative (node16/bundler) |
types |
N/A | N/A | N/A | Fallback for legacy node mode |
files |
N/A (npm-only) | N/A | N/A | N/A |
sideEffects |
Ignored | Authoritative | Authoritative | Ignored |
Metadata Fields That Change How a Package Is Consumed
The entry-point fields decide what loads; a second group of fields decides whether the package can be installed at all, how it is discovered, and what a security reviewer can learn about it without downloading anything. They are easy to treat as boilerplate, and each one has a failure mode that only appears in someone else’s environment.
engines is the field with the widest consequences and the least consistent enforcement. npm treats it as advisory by default and hard-fails only with engine-strict=true; pnpm fails by default; Yarn warns. That inconsistency means a too-narrow range irritates some consumers and blocks others, while a too-wide range silently ships code that crashes on the runtime you claimed to support. Declare the range you actually test in CI, and change it in a major release rather than a patch:
{
"engines": { "node": ">=20.11.0" },
"os": ["darwin", "linux", "win32"],
"cpu": ["x64", "arm64"]
}
os and cpu only matter for packages with native or platform-specific artefacts, and there they are load-bearing: a package that declares "os": ["linux"] cannot be installed on macOS at all, which is the desired behaviour for a Linux-only binary and a disaster for a pure-JavaScript library where someone copied the field from a template.
peerDependencies expresses “the consumer must supply this, and there must be exactly one copy”. Its companion peerDependenciesMeta marks a peer as optional, which is how a library declares support for a framework without requiring it:
{
"peerDependencies": { "react": ">=18", "react-dom": ">=18" },
"peerDependenciesMeta": { "react-dom": { "optional": true } }
}
Getting this wrong produces the single most-reported install error in the front-end ecosystem. A peer range that is too narrow ("react": "18.2.0") blocks every consumer on a newer patch; a peer that should have been a regular dependency produces a missing-module error at runtime for consumers who never installed it themselves.
The repository field earns a special mention because it is now load-bearing for verification rather than just documentation. Registries link it, provenance attestations record the same repository, and tools that compare the two rely on it being accurate — including the directory sub-field for packages published from a workspace:
{
"repository": {
"type": "git",
"url": "git+https://github.com/acme/toolkit.git",
"directory": "packages/my-library"
}
}
Omitting directory in a monorepo sends every reader of every package to the repository root, which for a twenty-package workspace is unhelpful enough that people stop clicking.
Fields That Run Code, and How to Keep Them Small
Two manifest fields cause code to execute on a consumer’s machine, and both deserve deliberate restraint.
scripts entries named preinstall, install, and postinstall run automatically when your package is installed. They exist for genuinely necessary work — compiling a native addon, downloading a platform-specific binary — and they are also the mechanism most abused by malicious packages, which is why an increasing number of organisations install with --ignore-scripts by default. The practical consequence for a library author: if your package requires an install script to function, it will silently break in those environments. Prefer designs that need no install step at all, and where a binary really is required, download it lazily on first use with a clear error message rather than at install time.
bin maps command names into the consumer’s node_modules/.bin, and its failure modes are mundane but persistent. The referenced file must have a shebang (#!/usr/bin/env node), must be included in the files array, and — on POSIX systems — must be executable in the tarball. npm sets the executable bit when it creates the symlink, but a file that was never marked executable in Git can still fail under some package managers and in Docker builds that copy node_modules between stages:
{
"bin": { "my-tool": "./dist/cli.mjs" },
"files": ["dist"]
}
git update-index --chmod=+x dist/cli.mjs # record the bit in Git, once
npm pack --dry-run | grep cli.mjs # confirm the file actually ships
A second bin subtlety: a single command whose name matches the package name can be written as a string ("bin": "./dist/cli.mjs"), while multiple commands require the object form. Mixing the two — an object whose only key differs from the package name — is legal but frequently unintended, and produces a command consumers cannot guess.
Finally, publishConfig overrides registry settings at publish time only, which makes it the correct home for two decisions that should not be left to whoever runs the publish:
{
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org",
"provenance": true
}
}
Scoped packages default to restricted access, so a first publish without access: "public" either fails or — worse, on a paid account — succeeds privately, and the maintainer discovers weeks later that nobody could install it. Pinning registry prevents a stray .npmrc from sending an internal package to the public registry, or a public package to an internal mirror. Both are one-line insurance against mistakes that are tedious to reverse.
Keeping the Manifest Honest Over Time
A manifest is written once and then edited by many hands, usually in a hurry, and the fields drift apart from the artefacts they describe. Three drifts account for nearly all of the “worked yesterday” reports.
The first is a types entry that outlives its file. A build tool is swapped, the output moves from dist/index.d.ts to dist/types/index.d.ts, and the top-level types field is updated while a types condition buried three levels deep in the exports map is not. TypeScript reads whichever it finds first, so the package appears fine to the author whose editor caches the old resolution and broken to a consumer on a cold install.
The second is a files array that lags a new output directory. Adding a dist/browser/ build without adding it to files produces a tarball whose exports map points at paths that do not exist — and because exports errors surface only when a specific condition is actually requested, the package installs cleanly and fails for the subset of consumers who use that condition.
The third is a version range in peerDependencies that was correct at the time and has quietly become a lie. Frameworks release majors; a peer range of ">=18 <19" written in 2024 blocks every consumer who has moved on, and nobody tells you — they just pick a different library.
All three are catchable in seconds, which argues for making the check part of the build rather than a review habit:
# every path referenced anywhere in the manifest must exist in the tarball
node --input-type=module -e '
import { readFileSync } from "node:fs";
import { execSync } from "node:child_process";
const pkg = JSON.parse(readFileSync("package.json", "utf8"));
const shipped = new Set(JSON.parse(execSync("npm pack --dry-run --json"))[0].files.map(f => f.path));
const refs = JSON.stringify([pkg.main, pkg.module, pkg.types, pkg.bin, pkg.exports])
.match(/\.\/[\w./-]+/g) ?? [];
const missing = [...new Set(refs)].map(r => r.slice(2)).filter(p => !shipped.has(p));
console.log(missing.length ? "MISSING: " + missing.join(", ") : "manifest paths all present");
'
Wire that into prepublishOnly and the manifest can no longer describe a package that does not exist. What it cannot check is intent — whether the ranges, the engine floor and the license still reflect what you support — and that is what the quarterly read-through of the manifest is for.
Guides in This Section
- main, module, and exports Field Precedence — which field wins when several are present, across Node.js, webpack, Vite, and TypeScript.
- The files Field and Controlling npm pack — restricting the published tarball with
files,.npmignore, andnpm pack --dry-run.
Related
- Mastering the package.json Exports Field — deep dive on conditional exports, sub-paths, and environment-specific routing.
- Implementing the sideEffects Flag Correctly — full guidance on declaring
sideEffectsaccurately for tree-shaking bundlers. - Navigating the Dual-Package Hazard — why conflicting
main/exportsentries can duplicate singleton state. - Understanding ESM vs CJS Module Formats — the format distinction that every field on this page ultimately routes between.
Back to Module System Fundamentals & Dual-Package Resolution