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.
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.
Implementation Checklist
- Validate
nameagainst registry availability and npm naming rules (lowercase, no spaces, URL-safe; scoped names take the form@scope/name). - Pin
versionto exact SemVer; strip pre-release tags (-alpha,-rc) before publishing to thelatestdist-tag. - Set
licenseto a valid SPDX identifier (MIT,Apache-2.0,BSD-3-Clause). - Apply
"private": trueat the monorepo root to blocknpm publishexecution.
# 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);}"
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.
{
"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')"
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.
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
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.
{
"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
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:*" |
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.
Related
- How to Configure package.json for Dual Modules — the exact dual
exportsrecipe and matching build output. - ESM and CJS Interoperability — the loader rules the
importandrequireconditions exist to satisfy. - TypeScript Declaration Publishing — wiring the
typescondition so declarations resolve under both formats. - npm Registry Publishing Workflows — how the finished manifest is validated and uploaded to the registry.
- Lockfile Management Strategies — recording and verifying the dependency versions this manifest declares.