Back to core workflows Fix dependency resolution Tune package metadata Jump to monorepo patterns

Understanding package.json Fields

The package.json manifest is the single contract between your source tree, the resolver, the bundler, and every consumer who installs your package — and a single misordered field can break all four without throwing an error locally. This page is a field-by-field reference for the manifest, organized by the job each group of fields performs: identity, module resolution, dependency classification, and workspace orchestration. It is part of the broader Core JavaScript Package Workflows, and the diagram below shows which resolver or tool reads each group of fields.

Which tool reads which package.json field group The manifest at the center feeds identity fields to the registry, exports to the resolver, dependency fields to the installer, and workspace fields to the task runner. package.json the manifest registry name, version, license resolver / bundler exports, type, types installer deps, peers, overrides task runner workspaces, engines
One manifest, four readers: identity fields go to the registry, exports to the resolver, dependency fields to the installer, and workspace fields to the task runner.

Core Metadata and Package Identity

Establish strict package identity using an RFC 1123-compliant name and a SemVer 2.0.0 version. Mandate SPDX-compliant license strings to satisfy automated compliance scanners, since a non-SPDX value such as a freeform "MIT-ish" will be rejected by license-policy gates. For monorepo roots, enforce private: true to prevent accidental publication of internal scaffolding — this is the single most effective guard against leaking a private repository to a public registry, because npm publish refuses to run on a private manifest entirely.

Identity fields The identity fields the registry and resolver read first. name + version registry coordinates type default module format main / module / exports entry resolution files / private what publishes
Identity fields decide the package name, visibility and entry.

Implementation Checklist

  • Validate name against registry availability and npm naming rules (lowercase, no spaces, URL-safe; scoped names take the form @scope/name).
  • Pin version to exact SemVer; strip pre-release tags (-alpha, -rc) before publishing to the latest dist-tag.
  • Set license to a valid SPDX identifier (MIT, Apache-2.0, BSD-3-Clause).
  • Apply "private": true at the monorepo root to block npm publish execution.
# Verify package name availability before commit
npm view <package-name> version 2>/dev/null || echo "Name available"

# Validate version format
node -e "const v=require('./package.json').version; if(!/^\d+\.\d+\.\d+/.test(v)) process.exit(1)"

# Confirm private flag at root
node -e "if(!require('./package.json').private){console.error('Missing private:true');process.exit(1);}"

The identity fields are read by different tools at different times, which is why an inconsistency between them surfaces in surprising places. The registry reads name and version as the package's coordinates; the resolver reads type to decide the default module format; bundlers and Node read main, module, and exports to find the entry; and the registry reads files and private to decide what publishes and whether it may. Treating these as one coherent identity — rather than fields edited in isolation — is what prevents a manifest that looks fine locally but resolves wrong for a consumer.

Two flags act as safety switches worth setting deliberately. private: true blocks publishing entirely, which belongs on every application and internal package that must never reach a registry; its removal is the explicit act that makes a package publishable. The files allowlist controls the tarball and fails safe — anything you forget is simply not published — whereas an .npmignore denylist fails open and ships whatever you forget to exclude. Preferring the allowlist keeps the published surface to exactly what you intend.

The name field carries more weight than it appears to, because it is simultaneously the registry coordinate, the import specifier consumers write, and — when scoped — the routing key that decides which registry resolves it. A scoped name like @acme/ui namespaces the package to your organization and enables per-scope private routing; an unscoped name lives in the shared global namespace where anyone can register a collision. For anything internal, scoping is a security decision as much as a naming one, because it is what makes an unambiguous private mapping possible.

Module Resolution and Export Maps

Configure explicit exports maps to replace the legacy main and module fields. Define conditional exports (types, import, require, default) to guarantee deterministic resolution across bundlers and runtimes. The resolver walks the conditions top to bottom and uses the first match, so the order of keys is load-bearing: place types first so a TypeScript consumer resolves declarations before any JavaScript condition is considered, and place default last as the catch-all. Once an exports map exists, any subpath you do not list becomes unreachable, which is what makes the map an encapsulation boundary rather than just a router.

Export map order Conditions are matched in declaration order, first match wins. types checker picks first import ESM consumers require CJS consumers default fallback
Order matters: put types first, then import, then require.
{
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/esm/index.js",
      "require": "./dist/cjs/index.js",
      "default": "./dist/esm/index.js"
    },
    "./utils": {
      "types": "./dist/utils.d.ts",
      "import": "./dist/esm/utils.js",
      "require": "./dist/cjs/utils.js"
    },
    "./package.json": "./package.json"
  }
}

Shipping two JavaScript formats from one manifest is the most error-prone part of this field, so the complete recipe — including the per-format types nesting and matching build output — is broken out in How to Configure package.json for Dual Modules. The underlying loader rules that the import and require conditions exist to satisfy are the subject of ESM and CJS Interoperability, and the types condition specifically must point at format-correct declarations or a consumer hits a "cannot find module" error, which is covered in TypeScript Declaration Publishing. The manifest you finalize here is exactly what gets validated and uploaded by the npm Registry Publishing Workflows.

# Verify ESM resolution locally
node -e "import('./dist/esm/index.js').then(() => console.log('ESM OK'))"

# Verify CJS resolution locally
node -e "require('./dist/cjs/index.js'); console.log('CJS OK')"

The exports map is evaluated strictly and in order, so its structure is a contract, not a hint. Node walks the conditions top to bottom and stops at the first match, which means the ordering — types first so type checkers resolve declarations, then runtime conditions from most specific to least, and default last — is load-bearing. A default placed above import short-circuits ESM resolution for every consumer, and a subpath you do not list becomes genuinely unreachable, which is the encapsulation benefit: consumers cannot couple to internal file layout you may want to refactor.

For a dual-format package, the map must also route types per condition. Under modern node16/nodenext resolution the type checker follows the same conditional exports as the runtime, so the require branch needs a CommonJS-flavored .d.cts declaration nested under its own types condition, or a require consumer gets the wrong-shaped type even when the JavaScript resolves correctly. Nesting a types condition inside both the import and require branches is what keeps editors and tsc resolving the format-appropriate declaration.

Subpath exports extend the encapsulation of the main exports entry to secondary entry points, letting a package expose your-lib/feature as a deliberate public path while keeping everything else unreachable. Each subpath key gets its own ordered condition block, so a package can offer a browser build and a node build per subpath, or expose a submodule to consumers while hiding the internal modules it is built from. The power of subpath exports is that they make the public API an explicit list — anything not exported cannot be imported — which frees you to refactor internal file layout without a breaking change.

The exports map also supersedes the legacy main, module, and browser fields, and mixing the two models causes confusing resolution. When an exports map is present, modern resolvers use it exclusively and ignore main, so a package that sets both an exports map and a main pointing at a different file will resolve to the exports entry under modern resolution and the main entry under old tooling — a split that ships different code to different consumers. The clean approach is to treat exports as the source of truth and keep main only as a fallback pointing at the same CommonJS entry the require condition uses.

Dependency Classification and Security Overrides

Differentiate dependencies, devDependencies, and peerDependencies to minimize the production install footprint and prevent runtime duplication. Runtime imports belong in dependencies; anything used only to build or test belongs in devDependencies; a framework the consumer already owns belongs in peerDependencies. Declaring a peer such as react as a direct dependency is the classic cause of two framework copies in one tree, which the broader Dependency Resolution Explained topic dissects in full.

Dependency fields Which install phase each dependency field feeds. Field Ships? Purpose dependencies yes runtime needs devDependencies no build + test peerDependencies provided host contract
Each field routes a package to a different install phase.

Use overrides (npm v8.3+), pnpm.overrides, or resolutions (Yarn v1) to patch vulnerable transitive dependencies deterministically. Keep overrides narrow and audited; a broad override can silently pin an incompatible major version into a sub-tree you never inspect. Align this pinning with strict Lockfile Management Strategies so the patched versions are recorded and verified on every install rather than re-resolved.

{
  "dependencies": { "lodash-es": "^4.17.21" },
  "devDependencies": { "typescript": "^5.7.0", "vitest": "^3.0.0" },
  "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" },
  "peerDependenciesMeta": { "react": { "optional": true } },
  "overrides": { "semver": "^7.5.4", "postcss": "^8.4.35" }
}

The CI workflow below installs with a frozen lockfile, runs an audit gate, and fails if the install mutated the lockfile — the three checks that together keep the dependency fields in this manifest honest.

# .github/workflows/dependency-audit.yml
name: Dependency & Lockfile Guard
on: [push, pull_request]
jobs:
  audit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
        with:
          version: 10
      - run: pnpm install --frozen-lockfile
      - run: pnpm audit --audit-level=high
      - name: Verify no lockfile drift
        run: git diff --exit-code pnpm-lock.yaml

The three dependency buckets encode a contract about who installs what, and misplacing a package breaks that contract in predictable ways. dependencies are installed for every consumer and ship to production; devDependencies are build and test tooling that never reaches a consumer; peerDependencies are requirements the consumer must provide, which is how a plugin uses the host's single copy of a framework rather than bundling its own. Declaring a framework as a direct dependency instead of a peer is the classic cause of two React copies in one tree and the broken context that follows.

Overrides are the escape hatch for patching a transitive dependency the graph resolved to a vulnerable or broken version. overrides (npm/pnpm) and resolutions (Yarn) rewrite a resolved version across the graph, which is the right tool for a security patch that exists only in a newer sub-dependency — but a blanket override can mask a real incompatibility and silently hold a package back. Scope each override as narrowly as the fix allows, document why it exists, and treat it as temporary debt to remove once upstream catches up.

Workspace Orchestration and Engine Constraints

Define a workspaces array (npm and Yarn) or a pnpm-workspace.yaml to enable local linking, controlled hoisting, and cross-package script execution. Configure engines to enforce Node.js and package manager versions, and pair it with packageManager so Corepack activates the exact tool. With --engine-strict, an install on an unsupported Node version fails immediately rather than producing a subtly broken tree.

Workspace Orchestration and Engine Constraints Define a workspaces array (npm and Yarn) or a pnpm-workspace.yaml to enable local linking, controlled hoisting, and cros Workspace Orchestration and Engine Constraints Define a workspaces array (npm and Yarn) or a pnpm-workspace.yaml to enable local linking, controlled hoisting, and cross-package script execution.
Workspace Orchestration and Engine Constraints — the core idea of this section at a glance.
{
  "engines": { "node": ">=20.0.0", "pnpm": ">=10.0.0" },
  "packageManager": "pnpm@10.4.1",
  "overrides": { "semver": "^7.5.4" },
  "workspaces": ["packages/*", "apps/*"]
}

How these fields drive topological builds, hoisting controls, and filtered execution is the focus of the Workspace Configuration Deep Dive.

# Enforce package manager version via corepack
corepack enable
corepack prepare pnpm@10.4.1 --activate

# Run workspace-aware scripts
pnpm --filter "@scope/ui" build
pnpm -r --parallel test

# Validate engine constraints before install
pnpm install --engine-strict

In a workspace, the manifest also encodes how internal packages reference each other. The workspace: protocol in a dependency range tells the package manager to resolve to the local package via a symlink during development and to rewrite the specifier to the real published version at publish time, so a change is live everywhere immediately yet external consumers still receive a normal semver range. Hardcoding a version instead of the protocol reintroduces the stale-copy problems the protocol exists to eliminate, which is why workspace-internal dependencies should almost always use it.

Engine constraints are a runtime contract consumers rely on, and the common mistake is leaving them aspirational. A package that uses a Node 20 API while declaring engines.node as >=16 installs cleanly for a Node 16 consumer and crashes at runtime, because the field advertised support the code does not actually have. Verify the declared floor in CI against the lowest supported version — run the test suite on that version — so the contract is real, and set engine-strict in .npmrc where a wrong runtime would genuinely break rather than merely warn. An honest, tested engines floor is the difference between a package that runs where it claims and one that fails on the runtime it advertises.

Common Anti-Patterns

Anti-Pattern Impact Remediation
Using main/module alongside exports Bundler resolution conflicts, broken tree-shaking Delete main/module; rely on exports, keeping only a main fallback for pre-exports toolchains
Omitting types in conditional exports TS consumers fall back to implicit @types or fail outright Always declare "types" first in each export branch
Leaving private unset at the monorepo root Accidental npm publish of scaffolding or CI secrets Set "private": true in the root package.json
Using ^/~ for peerDependencies Incompatible runtime versions in consumer apps Use explicit minimums such as ">=18.0.0"
Hardcoding file: paths in dependencies Broken CI installs, non-portable graphs Use the workspace protocol: "workspace:*"
Common Anti-Patterns Common Anti-Patterns in production JavaScript package workflows. Common Anti-Patterns Common Anti-Patterns in production JavaScript package workflows.
Common Anti-Patterns — the core idea of this section at a glance.

The anti-patterns in a manifest tend to be sins of omission that pass local testing and fail for consumers. Relying on implicit main/module instead of an explicit exports map lets bundlers resolve the wrong entry; emitting one declaration for both formats gives require consumers the wrong types; declaring a framework as a direct dependency doubles it in the consumer's tree; and leaving engines aspirational ships a package that crashes on the runtime it claims to support. Each is invisible in a source-importing test and only surfaces through the published resolution, which is precisely why the manifest deserves automated, consumer-accurate validation.

Engine constraints and the packageManager contract

Two fields govern reproducibility before a single dependency resolves, and both are commonly left aspirational to the point of being misleading. The engines.node field declares the runtime a package supports, and it earns its keep only when it is a tested floor rather than a hopeful annotation: a package that relies on Node 20 features while declaring >=14 installs cleanly for a Node 14 consumer and crashes at runtime. Verify the declared floor in CI against the lowest supported version so the contract is real, and set engine-strict where a wrong runtime would genuinely break the build.

Reproducibility fields engines floor plus a pinned resolver govern reproducibility. engines.node >= tested floor packageManager one pinned resolver Corepack activates it everywhere reproducible tree lockfile decides
A tested engines floor and a pinned packageManager make installs a function of the lockfile alone.

The packageManager field pins the exact resolver, and with Corepack it guarantees every checkout — laptop or CI — activates the identical version. This matters because npm, pnpm, and Yarn each hoist and resolve slightly differently across major versions, so a mismatch produces lockfile churn nobody can explain and installs that differ from what was reviewed. Declaring packageManager makes the installed tree a function of the lockfile alone rather than of whatever version happened to be on the machine, which is the foundation every other reproducibility guarantee builds on.

Reading a manifest as a set of tool contracts

The most useful mental model for package.json is that it is not one document but a bundle of separate contracts, each read by a different tool at a different phase. Skimming it top to bottom hides that structure; grouping the fields by their consumer reveals it. The registry contract is name, version, files, private, and publishConfig. The resolution contract is type, main, module, exports, and the nested types conditions. The dependency contract is the three buckets plus overrides and peerDependenciesMeta. The reproducibility contract is engines and packageManager. The build contract is scripts and sideEffects.

Manifest contracts Fields grouped by the tool that reads them. Contract Fields Checker registry name, files, private npm pack --dry-run resolution type, exports, types publint + attw dependency deps, overrides npm ls / pnpm why
The manifest is a bundle of contracts, each read by a different tool at a different phase.

Seeing the manifest this way makes validation tractable, because each contract has a specific checker: publint and @arethetypeswrong/cli for resolution and types, npm pack --dry-run for the registry contract, npm ls/pnpm why for the dependency contract, and a CI Node-version check for reproducibility. A change to one field can violate a contract read by a tool you did not run, which is why the manifest deserves automated validation across all of them rather than a visual once-over — a misordered exports key or a missing .d.cts passes review and fails a consumer's install.

Scripts, lifecycle hooks, and the build contract

The scripts field is a contract about how the package is built and what runs automatically, and its lifecycle hooks are powerful enough to be dangerous if used carelessly. prepublishOnly runs before a publish and is the right place for the full build-and-validate gate, so a broken artifact cannot be published by hand. prepare runs on install from git and after npm install in the package itself, useful for building a package installed directly from a repository. The pre/post hooks wrap a named script automatically, so prebuild and postbuild bracket build without any explicit wiring.

Lifecycle hooks pre/post wrap a script; prepublishOnly gates the publish. prebuild runs first build the script prepublishOnly gate before publish
Lifecycle hooks automate the build, but postinstall runs on every consumer's machine.

The hook to treat with suspicion is postinstall, because it runs on every consumer's machine with their privileges whenever they install your package. Reserve it for genuinely local, offline work — never for network-dependent operations, environment-specific setup, or anything that would surprise a consumer — because it is both a supply-chain risk and a reproducibility hazard. Combined with sideEffects, which tells bundlers whether importing a module has observable effects, the build-related fields determine both how your package is produced and how efficiently a consumer can use it, so they warrant the same care as the resolution fields.

publishConfig and controlling the publish

publishConfig overrides selected settings at publish time, and it is the field that keeps a publish going where you intend regardless of a developer's local environment. Setting publishConfig.registry pins the target registry, so an internal package cannot be pushed to the public index because someone's default registry was misconfigured; setting publishConfig.access to restricted keeps a scoped package private by default. These are small guards against mistakes that are often irreversible — a proprietary package published publicly, or a private one accidentally exposed.

Publish intent publishConfig pins registry and access at publish time. publishConfig.registry pin the target access: restricted private by default files allowlist only intended output prepublishOnly validate first
Encoding publish intent in the manifest makes the safe path the default.

The broader lesson is that the manifest is where you encode publish intent so it does not depend on human memory or local configuration. A package that declares its registry, its access level, its files allowlist, and its prepublishOnly validation has made the safe path the default: the publish goes to the right place, ships the right files, runs the right checks, and cannot easily be done wrong. Treating publishConfig and its neighbors as the place to make mistakes structurally difficult — rather than relying on a careful engineer at release time — is what separates a package that is occasionally published correctly from one that is always published correctly.

Frequently Asked Questions

Should I use main or exports for modern package distribution? Always prioritize exports. It provides explicit, secure resolution paths, hides internal files, and supports conditional loading for ESM, CJS, and types. Keep main only as a fallback for tooling that predates the exports map.

How do I enforce strict dependency versions across a monorepo? Use overrides (npm) or resolutions (Yarn) at the root package.json to force specific transitive versions, then combine that with engines constraints and a frozen lockfile so the pins are verified on every install.

What is the correct way to handle peerDependencies in library authoring? Declare peers with explicit minimum versions (e.g., "react": ">=18.0.0") rather than caret/tilde ranges, and use peerDependenciesMeta with optional: true for integrations that should not block installs when the peer is absent.

Why does field order matter inside an exports map? Node and TypeScript use the first matching condition, so a require listed before types would resolve JavaScript before declarations. Keep types first and default last in every branch.

Why does the order of keys in exports matter?

Node evaluates conditions top to bottom and stops at the first match. Put types first, then runtime conditions from most specific to least (import, require), and default last. A default above import short-circuits ESM resolution for every consumer.

What's the difference between files and .npmignore?

files is an allowlist that fails safe — anything you forget is simply not published. .npmignore is a denylist that fails open — anything you forget to exclude ships. Prefer files so the published surface is exactly what you intend.

Why treat engines as a tested floor rather than a hint?

Because it is a runtime contract consumers rely on. An aspirational >=14 on a package that uses Node 20 features installs cleanly for a Node 14 user and crashes at runtime. Verify the declared floor in CI against your lowest supported version.

When should I use the workspace: protocol versus a version range?

Use workspace: for dependencies on other packages in the same monorepo. It resolves to a local symlink in development and rewrites to the real published version at publish time, so internal changes are live immediately while external consumers still get a normal semver range.

Why is postinstall risky?

It runs on every consumer's machine with their privileges whenever they install your package. Reserve it for local, offline work — never network-dependent or environment-specific operations — because it is both a supply-chain vector and a reproducibility hazard.

Which package.json fields matter most for a published library?

The resolution contract — type, exports with nested types conditions — plus files, engines, and sideEffects. Those decide how consumers resolve your code and types, what ships, which runtime you support, and whether they can tree-shake you. Validate them with publint and attw against the packed tarball.

How do I validate my package.json before publishing?

Run publint for structural exports and field checks, @arethetypeswrong/cli --pack for type resolution across every mode, and npm pack --dry-run for the tarball contents. Together they validate the manifest the way a consumer's tooling resolves it, catching mistakes before publish.

Does the order of exports conditions matter?

Yes — Node evaluates them top to bottom and stops at the first match. Put types first, then runtime conditions from most specific to least (import, require), and default last. A default placed above import short-circuits ESM resolution for every consumer.

Related

Core JavaScript Package Workflows