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.


Which tool reads which package.json field Four stages showing that main and module are read by legacy bundlers, exports is read by Node.js and modern bundlers, types is read by TypeScript, and files plus sideEffects are read by npm pack and tree-shaking bundlers respectively. Distribution field → resolver map main / module legacy bundlers exports Node.js, webpack 5+ types TypeScript compiler files / sideEffects npm pack, tree-shaking

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 publish succeeds, but the installed package is missing dist/ entirely and only source files are usable.

Root cause: No files field 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 files array listing your build output directory, and verify with npm pack --dry-run as 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": false was 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 sideEffects an 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.ts files exist in the tarball.

Root cause: The top-level types field points at a path that does not exist in the published files allowlist, or the exports map’s types condition is missing or ordered after import/require.

Fix: Confirm the declaration path is included by files, and place "types" first in every exports branch — never after import or require.

HAZARD PREVENTION

Symptom: npm install works but require() throws Cannot find module './package.json' when a tool tries to read package metadata at runtime.

Root cause: A strict exports map was added without an explicit "./package.json": "./package.json" entry, and Node.js now refuses any path not listed in exports.

Fix: Always add the ./package.json self-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.

Which distribution fields to set A top-to-bottom decision sequence: start from your build output, decide whether legacy tooling must be supported, add exports either way, then restrict files and declare sideEffects before publishing. Build ESM + CJS output Support tooling pre-Node 12.7? Yes → keep main/module/types Add exports + files + sideEffects Validate with publint, then publish

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.

Manifest fields grouped by effect Three groups of package.json fields. Installability fields such as engines, os, cpu and peerDependencies decide whether an install succeeds. Discoverability fields such as name, keywords, description and homepage decide how the package is found. Provenance fields such as repository, license and funding decide what a reviewer can verify. Three jobs the metadata fields do can it install? engines.node os / cpu peerDependencies peerDependenciesMeta optionalDependencies failures here are loud and land on the consumer can it be found? name (and its scope) description keywords homepage README (implicit) failures here are silent — nobody reports them can it be trusted? repository (+ directory) license funding publishConfig.access scripts (what runs on install) reviewed by tools and by procurement teams

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



Back to Module System Fundamentals & Dual-Package Resolution