Skip to content
JavaScript

package.json Explained: Every Field That Actually Matters

·9 min read
package.json Explained: Every Field That Actually Matters - JavaScript guide from JSON Prettifier

Quick answer: package.json is the manifest of a Node.js project. The fields you will actually touch are name and version (identity), scripts (commands run with npm run), dependencies and devDependencies (what to install, with semver ranges like ^1.2.0), type (CommonJS or ES modules), main/exports (entry points), and private (block accidental publishing). It must be strict JSON: no comments, no trailing commas.

You cloned a repo, ran npm install, and got npm ERR! code EJSONPARSE. Someone left a trailing comma in package.json. Or you published a library and a user reports that import fails while require works. Or CI runs a different version of a dependency than your laptop. Every one of these is a package.json field doing something you did not expect. Here is each field that matters, what it controls, and how people get it wrong.

Inspect a sample configuration in the JSON Viewer and use JSON Compare to review the values that changed between versions.

A Realistic package.json

{
  "name": "@acme/invoice-service",
  "version": "2.4.1",
  "private": true,
  "type": "module",
  "description": "Generates and emails invoices",
  "engines": { "node": ">=20" },
  "scripts": {
    "dev": "node --watch src/server.js",
    "build": "tsc -p tsconfig.json",
    "prebuild": "rimraf dist",
    "test": "vitest run",
    "lint": "eslint .",
    "start": "node dist/server.js"
  },
  "dependencies": {
    "express": "^4.19.2",
    "pino": "^9.0.0",
    "zod": "~3.23.0"
  },
  "devDependencies": {
    "eslint": "^9.0.0",
    "rimraf": "^5.0.0",
    "typescript": "^5.4.0",
    "vitest": "^1.6.0"
  }
}

name and version: Identity

name must be lowercase, URL-safe, and at most 214 characters. A scope such as @acme/ namespaces it under an npm organization; scoped packages are private on the registry by default and need --access public to publish openly.

version must be valid semver: MAJOR.MINOR.PATCH, optionally with a pre-release suffix like 3.0.0-beta.2. Do not edit it by hand; npm version patch (or minor, major) bumps it, commits, and creates a git tag in one step. For applications that are never published the version is mostly decorative, but keep it valid or npm complains.

scripts: Your Project's Command Line

Any key under scripts runs with npm run <key>. Four names are special enough to drop the run: npm start, npm test, npm stop, and npm restart. If start is missing, npm start falls back to node server.js.

Scripts run with node_modules/.bin on the PATH, which is why "build": "tsc" works without npx or a path prefix.

pre and post hooks

For any script X, npm automatically runs preX before it and postX after it. In the example above, npm run build executes prebuild (clean dist), then build. The same applies to pretest, postinstall, and prepublishOnly, which is the standard place to build before publishing. Hooks are convenient and also a classic source of confusion when a colleague cannot work out why npm test runs the linter first.

One caution: postinstall runs on every consumer's machine when they install your package. Keep it out of libraries unless it is genuinely required.

dependencies, devDependencies, peerDependencies

FieldPurposeInstalled for consumers?Examples
dependenciesCode your package needs at runtimeYesexpress, zod
devDependenciesBuild, test, lint toolingNotypescript, vitest, eslint
peerDependenciesMust be provided by the consumer, onceNo (consumer installs)react for a React component library
optionalDependenciesNice to have; install failure is ignoredIf it buildsfsevents

The practical rule: if deleting the package would break npm start in production, it is a dependency. If it would only break npm run build or npm test, it is a devDependency. npm install --omit=dev (older: --production) installs only the first group, which is what a Docker runtime image should do.

peerDependencies solve the one-copy problem. A React component library must use the same React instance as the app that renders it, so it declares "react": ">=18" as a peer and lets the app supply it. npm 7 and later install missing peers automatically, and fail on conflicts unless you pass --legacy-peer-deps, which is a flag to understand rather than reach for by reflex.

Add packages with the CLI rather than editing the file: npm install express and npm install -D vitest write the correct field, the correct range, and update the lock file in one go.

Semver Ranges: ^ vs ~ vs Exact

RangeMeaningAllows
1.2.3Exactly 1.2.31.2.3 only
^1.2.3Same major, at least 1.2.3>=1.2.3 <2.0.0
~1.2.3Same minor, at least 1.2.3>=1.2.3 <1.3.0
^0.2.30.x: caret acts like tilde>=0.2.3 <0.3.0
>=1.2.3 <2Explicit rangeas written
1.x or 1Any 1.something>=1.0.0 <2.0.0
*Anything (avoid)>=0.0.0
npm:name@1.0.0Alias another packageinstalls name as this key
file:../libLocal pathsymlink
github:user/repo#tagGit sourceclones at ref

npm install defaults to the caret, which trusts that maintainers follow semver and do not break things in minor releases. They usually do; sometimes they do not. The lock file is what protects you in the meantime. The 0.x row is the one that surprises people: before 1.0.0, semver allows minor versions to break, so ^0.2.3 will not pull 0.3.0.

package-lock.json: Why It Exists

A range like ^4.19.2 can resolve to a different version next week. The lock file freezes the exact version, resolved URL, and integrity hash of every package in the tree, including the hundreds you never typed. npm ci installs from the lock file strictly and fails if it disagrees with package.json; that is the command to use in CI and Docker builds. npm install may update the lock file. Commit the lock file, and do not hand-edit it. It is valid JSON, so you can inspect it in any viewer, but at thousands of lines it is a better fit for the compare two JSON files workflow than for reading top to bottom.

engines: Declaring the Node Version

"engines": { "node": ">=20.0.0", "npm": ">=10" }

By default npm only warns when the running Node does not satisfy engines. Add engine-strict=true to a project .npmrc to turn the warning into an install failure. Yarn and pnpm are strict by default. Pair it with a .nvmrc or .node-version file so the version is easy to switch to.

type: CommonJS or ES Modules

"type": "module" makes Node treat every .js file in the package as an ES module: import/export work, require and __dirname do not. Leave it out (or set "commonjs") and .js is CommonJS. The .mjs and .cjs extensions override the setting per file. Flipping this field on an existing project is the single most common cause of ReferenceError: require is not defined. The reading JSON files in Node.js guide shows how the choice changes the way you load a JSON file.

main, exports, and types: Entry Points

main is the legacy entry point: the file loaded by require("your-pkg"). exports is the modern replacement, and it does two things main cannot: serve different files to import and require, and hide everything not listed.

{
  "name": "@acme/money",
  "main": "./dist/index.cjs",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js",
      "require": "./dist/index.cjs"
    },
    "./currencies": "./dist/currencies.js",
    "./package.json": "./package.json"
  }
}

Once exports exists, import "@acme/money/dist/internal.js" throws ERR_PACKAGE_PATH_NOT_EXPORTED, which is the point: consumers can only reach the surface you chose. Keep main for older bundlers. types (TypeScript) tells the compiler where the declarations are; inside exports, the types condition must come first, because conditions are matched in order.

files, private, and the Publishing Fields

  • files: an allowlist of what goes into the published tarball, e.g. ["dist", "README.md"]. package.json, the README, and the LICENSE are always included. Without it, everything not in .npmignore or .gitignore ships, including your tests and sometimes your .env. Run npm pack --dry-run to see the list before publishing.
  • private: true: makes npm publish refuse. Set it on every application and monorepo root. It costs nothing and prevents a very public mistake.
  • bin: maps command names to scripts so npx your-tool works.
  • license, repository, description, keywords: metadata shown on the npm page. license should be an SPDX identifier such as "MIT".
  • packageManager: e.g. "pnpm@9.1.0", used by Corepack to pin the package manager version for the whole team.

workspaces: Monorepos

{
  "name": "acme-monorepo",
  "private": true,
  "workspaces": ["packages/*", "apps/*"]
}

Each matching folder has its own package.json. A single npm install at the root installs everything, hoists shared dependencies, and symlinks local packages so apps/web can depend on @acme/money without publishing it. Run a script in one workspace with npm run build -w packages/money, or in all of them with --workspaces.

Keep It Valid JSON

package.json is parsed with a strict JSON parser. It is not JavaScript, not JSON5, and there is no comment syntax. This is the error you get for a trailing comma:

npm ERR! code EJSONPARSE
npm ERR! path /home/priya/invoice-service/package.json
npm ERR! JSON.parse Unexpected token } in JSON at position 412 while parsing near '...vitest run",\n  },\n  "depen...'
npm ERR! JSON.parse Failed to parse JSON data.
npm ERR! JSON.parse Note: package.json must be actual JSON, not just JavaScript.

The "position 412" is a character offset, which is not much help in a 90-line file. Paste the file into JSON Prettifier for the line and column, or read the trailing comma guide for the fix in every editor. If you really need to annotate something, the convention is a key that npm ignores:

{
  "//": "Pinned: 3.23.x has a breaking change in the schema API",
  "dependencies": { "zod": "~3.23.0" }
}

The full story on why JSON has no comments and what the alternatives are is in comments in JSON.

Key order and sorting

Key order does not matter to npm, but a convention makes diffs readable: name, version, description, then scripts, then dependencies last. npm itself alphabetizes the entries inside dependencies and devDependencies every time it writes the file, so if you add one by hand, keep it sorted or the next npm install will move it and produce a noisy diff. The sort-package-json package enforces the full convention; for a quick review of a dependency block, the Sort Keys option in a JSON formatter does the same thing in a paste.

Common Mistakes

When JSON imports break after a module-mode or build change, follow the JSON import attribute deployment checklist. It distinguishes compiler acceptance from the syntax, asset paths, and exports the production runtime actually loads.

  • Build tools in dependencies. Your production image installs TypeScript, ESLint, and a test runner it never uses. Move them to devDependencies.
  • Runtime packages in devDependencies. Works locally, then Cannot find module 'express' in production because the deploy used --omit=dev.
  • Editing the lock file by hand, or deleting it to "fix" an install. Deleting it silently upgrades every transitive dependency to the newest allowed version. Fix the conflict instead.
  • Using * or latest as a range. Every install can pull a new major. Use the caret and let the lock file pin.
  • Forgetting private: true on an app. One mistyped command and your internal service is on the public registry.
  • Setting type: module without checking the codebase. Every require and __dirname breaks at once. Migrate deliberately, or use .mjs for new files.
  • Publishing without files or exports. Consumers import from your internal paths, and now you cannot refactor without breaking them.

Frequently Asked Questions

What is the difference between dependencies and devDependencies?
dependencies are packages your code imports at runtime and get installed by anyone who installs your package. devDependencies are tooling such as test runners, bundlers, and linters; they are installed for local development and CI but skipped with npm install --omit=dev and never installed for consumers of your library.
What does the caret (^) mean in package.json versions?
^1.4.2 allows any version that is at least 1.4.2 and below 2.0.0, in other words any minor or patch update within the same major. The tilde (~1.4.2) is stricter and allows only patch updates below 1.5.0. For 0.x versions, the caret behaves like the tilde.
Can I add comments to package.json?
No. package.json must be strict JSON and comments are a syntax error that stops npm from reading the file. The common workaround is a key such as "//" or "_comment" holding a string, which npm ignores.
What is package-lock.json for and should I commit it?
It records the exact version and integrity hash of every installed package, including transitive ones, so that npm ci reproduces the same tree on every machine. Commit it for applications. For published libraries it is conventionally committed too, but it is not shipped to consumers.
What does type: module do in package.json?
It tells Node.js to treat .js files in that package as ES modules, so import and export work and require is unavailable. Without it, .js files are CommonJS. You can override per file with the .mjs and .cjs extensions.
Why does npm say EJSONPARSE?
Your package.json is not valid JSON. The usual causes are a trailing comma after the last item in an object or array, a missing comma between entries, single quotes instead of double quotes, or a comment. Validate the file in a JSON tool to get the exact line.

Fix EJSONPARSE in Seconds

Paste your package.json, see the exact line of the trailing comma, and sort the dependency keys before you commit. Nothing leaves your browser.

Validate My package.json