Skip to main content

πŸ—‚οΈ Package.json & Dependency Management

If package.json feels like a formality npm generates and forgets, this lesson will change your mind. It is your project's rΓ©sumΓ©, recipe book, and instruction manual rolled into one file: it declares who you depend on, how those dependencies may update, and every task your team can run. Master it and you master the shape of every Node project you'll ever open.

Week 3 · Day 2 (Tuesday: NPM and Package Management) · Lecture 2

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Identify each metadata field in package.json and write valid values for it
  • Distinguish the four dependency types and place a package in the correct one
  • Explain what peerDependencies solve and when a library should declare them
  • Choose an appropriate version constraint (^, ~, exact) for a given dependency
  • Configure lifecycle and custom scripts, including cross-platform patterns
  • Diagnose and resolve a dependency version conflict using overrides and npm ls

Estimated Time: 75 minutes

Practice: Author a complete package.json for a full-stack app and resolve a version clash.

In This Lesson

Anatomy of package.json

Every field in package.json serves one of four broad purposes: describing the project (metadata), listing what it needs (dependencies), automating tasks (scripts), and controlling how it builds and publishes (configuration). Keeping this mental map handy makes even an unfamiliar manifest readable at a glance.

graph TD A[package.json] --> B[Metadata] A --> C[Dependencies] A --> D[Scripts] A --> E[Configuration] B --> B1[name & version] B --> B2[description & keywords] B --> B3[author & license] B --> B4[repository & homepage] C --> C1[dependencies] C --> C2[devDependencies] C --> C3[peerDependencies] C --> C4[optionalDependencies] D --> D1[lifecycle scripts] D --> D2[custom scripts] E --> E1[engines] E --> E2[main / module / types] E --> E3[files]

Metadata Fields

name and version β€” the identity

Together, name and version uniquely identify your package on the registry. The name has strict rules; the version must be valid SemVer.

{
  "name": "my-awesome-project",
  "version": "1.2.3"
}
Rule for nameValidInvalid
Lowercase onlymy-packageMy Package
No spaces; hyphens/underscores OKreact-carouselreact carousel
No file extensiondate-utilsdate-utils.js
May be scoped@mycompany/utilsnode_modules (reserved)

description and keywords β€” discoverability

{
  "description": "A utility library for formatting dates and times",
  "keywords": ["date", "time", "format", "datetime", "utility"]
}

Keep the description concrete β€” say what problem the package solves, not how great it is. Keywords feed npm's search index, so choose terms a user would actually type.

author, license, and repository

{
  "author": "Jane Doe <jane@example.com> (https://janedoe.com)",
  "license": "MIT",
  "repository": {
    "type": "git",
    "url": "https://github.com/username/repo.git"
  },
  "bugs": { "url": "https://github.com/username/repo/issues" },
  "homepage": "https://project-website.com"
}

πŸ’‘ Which license?

MIT and ISC are permissive and allow commercial use β€” the common choice for open source. Apache-2.0 adds an explicit patent grant. GPL-3.0 is copyleft: derivative works must also be open source. Use the SPDX identifier string (e.g. "MIT"), or "UNLICENSED" for proprietary code you never intend to publish.

The Four Dependency Types

npm sorts your dependencies into four buckets, and the bucket decides when a package gets installed. Choosing correctly keeps production installs small and prevents duplicate-copy bugs.

Four dependency types: dependencies for production, devDependencies for development, peerDependencies required by the host, optionalDependencies that may fail dependencies Required to run the app Installed in production express, react, mongoose devDependencies Needed only while developing Skipped in production installs jest, eslint, webpack peerDependencies Host project must provide it Prevents duplicate copies react (for a plugin/lib) optionalDependencies Nice to have; install may fail Code must have a fallback fsevents (macOS only)
The bucket you choose controls when β€” and whether β€” a package is installed.

dependencies & devDependencies

{
  "dependencies": {
    "express": "^4.18.2",
    "lodash": "~4.17.21"
  },
  "devDependencies": {
    "jest": "^29.5.0",
    "eslint": "^8.38.0",
    "webpack": "^5.80.0"
  }
}

The distinction becomes concrete when someone else installs your package as a dependency: npm pulls in your dependencies but skips your devDependencies. Your test framework shouldn't ship to their production server.

peerDependencies β€” "you bring your own React"

A plugin like a React component library needs React to function, but it must use the same React instance as the host app β€” two copies of React break hooks. So instead of listing React as a dependency, the library declares it as a peer: "I need React 16.8+, and I expect you to provide it."

{
  "name": "my-react-components",
  "peerDependencies": {
    "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
    "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
  },
  "peerDependenciesMeta": {
    "react-dom": { "optional": true }
  }
}

optionalDependencies β€” install may fail, and that's OK

Some packages are platform-specific (macOS file-watching, native compilers). Listing them as optional means a failed install won't abort everything β€” but your code must handle their absence:

// Fall back gracefully if the optional native module isn't present
let sass;
try {
  sass = require('node-sass');       // fast native binding
} catch (e) {
  console.warn('node-sass unavailable β€” using pure-JS fallback');
  sass = require('sass');            // slower, but always installs
}

Version Constraints

You met SemVer ranges in the last lesson. Here's the full toolbox, so you can pick the right amount of freedom for each dependency:

// Exact β€” no updates at all
"lodash": "4.17.21"

// Caret β€” minor + patch, lock major   (>=4.17.21 <5.0.0)
"lodash": "^4.17.21"

// Tilde β€” patch only, lock minor       (>=4.17.21 <4.18.0)
"lodash": "~4.17.21"

// Comparators and hyphen ranges
"lodash": ">=4.17.0 <4.18.0"
"lodash": "4.17.0 - 4.17.21"

// Wildcards
"lodash": "4.x"           // >=4.0.0 <5.0.0
"lodash": "4.17.x"        // >=4.17.0 <4.18.0

// OR multiple ranges
"lodash": "^4.17.0 || ^5.0.0"

// From Git or a local path
"lib": "github:owner/lib#4.17.21"
"my-module": "file:../my-module"

How npm resolves a range

Package A requires  lodash@^4.17.0
Package B requires  lodash@^4.17.15
β†’ npm installs lodash@4.17.21  (newest version satisfying BOTH)

Package A requires  react@^17.0.0
Package B requires  react@^18.0.0
β†’ No single version satisfies both β€” peer dependency warning/error

βœ… A sensible default policy

Use ^ for well-maintained libraries you trust to honor SemVer. Tighten to ~ for anything where a surprise behavior change would hurt. Pin an exact version for truly critical, hard-to-test dependencies (a database driver, a payment SDK). Avoid "*" and "latest" in committed code β€” they make builds non-reproducible.

Scripts Configuration

Lifecycle scripts run automatically

Certain script names are hooks npm fires at specific moments β€” you don't call them directly. The most useful are the pre/post pairs and the publish hooks:

{
  "scripts": {
    "prepare": "husky install",              // after install & before publish
    "prepublishOnly": "npm test && npm run build", // only on npm publish
    "pretest": "npm run lint",               // before "npm test"
    "test": "jest",
    "posttest": "npm run cleanup"            // after "npm test"
  }
}

Custom scripts are your project's control panel

{
  "scripts": {
    "start": "node server.js",
    "dev": "nodemon server.js",
    "test": "jest",
    "test:coverage": "jest --coverage",
    "lint": "eslint .",
    "lint:fix": "eslint . --fix",
    "build": "webpack --mode production",
    "clean": "rimraf dist",
    "prebuild": "npm run clean",
    "format": "prettier --write \"src/**/*.{js,jsx,json,css}\"",
    "validate": "npm run lint && npm test && npm run build"
  }
}

Note the naming convention: a colon (test:coverage) groups related variants, and a validate-style script chains several checks into one gate you can run before every commit.

Cross-platform scripts

A script that works on your Mac may fail on a teammate's Windows machine. Environment-variable syntax and file deletion differ between shells. Small helper packages smooth this over:

{
  "scripts": {
    "dev": "cross-env NODE_ENV=development node server.js",
    "clean": "rimraf dist",
    "build": "run-s clean compile",
    "compile": "tsc"
  },
  "devDependencies": {
    "cross-env": "^7.0.3",     // set env vars the same way everywhere
    "rimraf": "^5.0.0",        // cross-platform "rm -rf"
    "npm-run-all": "^4.1.5"    // run-s / run-p for sequential/parallel
  }
}

⚠️ NODE_ENV=production npm start isn't portable

Prefixing a variable inline works in bash but throws on Windows cmd. Use cross-env in your scripts so every contributor β€” and every CI runner β€” behaves identically.

Advanced Configuration

Engine constraints

{
  "engines": {
    "node": ">=18.0.0",
    "npm": ">=9.0.0"
  }
}

By default engines is advisory β€” npm only warns. Enforce it with an .npmrc containing engine-strict=true, which turns a mismatch into a hard error.

Entry points and the files whitelist

{
  "main": "dist/index.js",        // CommonJS entry point
  "module": "dist/index.esm.js",  // ES module entry point
  "types": "dist/index.d.ts",     // TypeScript type definitions
  "exports": {
    ".": {
      "import": "./dist/index.esm.js",
      "require": "./dist/index.js"
    }
  },
  "bin": { "my-cli": "./cli.js" }, // installs an executable command
  "files": ["dist/", "README.md"] // ONLY these get published
}

The files array is a publish whitelist: only the listed paths (plus package.json, README, and LICENSE, which are always included) go into the tarball. It's the cleanest way to keep your source, tests, and configs out of the published package.

Guarding against accidental publish

{
  "private": true,             // npm refuses to publish this package
  "publishConfig": {
    "access": "public",        // publish scoped packages publicly
    "registry": "https://registry.npmjs.org/"
  }
}

πŸ’‘ "private": true is cheap insurance

Set it on any app or internal repo you never mean to publish. It makes an accidental npm publish fail loudly instead of leaking your code to the public registry.

Resolving Conflicts

When two of your dependencies each need an incompatible version of a third package, npm normally installs both copies nested inside node_modules. That's fine for most libraries β€” but for singletons (React) or when you need to force a security patch, you have to intervene.

graph TD A[Your Project] --> B[Package A] A --> C[Package B] B --> D["lodash @^4.0.0"] C --> E["lodash @^3.0.0"] D --> F[Version conflict] E --> F F --> G[Resolution options] G --> H[overrides in package.json] G --> I[npm dedupe] G --> J[Update the offending package]

The tools

# See which versions of a package are installed and WHY
npm ls lodash          # every copy in the tree
npm explain lodash     # the dependency chain that pulled it in

# Try to flatten duplicate copies
npm dedupe

When you must force a version everywhere, npm's overrides (npm 8.3+) is the sanctioned tool:

{
  "overrides": {
    "lodash": "^4.17.21",          // force this version project-wide
    "package-a": {
      "lodash": "^4.17.21"         // …or only inside package-a's subtree
    }
  }
}

⚠️ Overrides are a last resort

Forcing a version can break a dependency that genuinely relied on the old one. Prefer updating the outdated package first; reach for overrides only to patch a vulnerability you can't otherwise clear, and test thoroughly afterward. (Yarn's equivalent is resolutions.)

Practice & Quiz

πŸ‹οΈ Exercise 1: Author a full-stack package.json

Goal: Write a manifest for a MERN-style app. It should have Express and Mongoose as runtime deps, Nodemon and Jest as dev deps, a dev script using nodemon, a test script, and a Node engine constraint of 18+.

πŸ’‘ Hint

Runtime deps (Express, Mongoose) go in dependencies; tooling (Nodemon, Jest) in devDependencies. The engine goes in an engines object.

βœ… Solution
{
  "name": "fullstack-app",
  "version": "1.0.0",
  "description": "A full-stack JavaScript application",
  "main": "server/index.js",
  "scripts": {
    "start": "node server/index.js",
    "dev": "nodemon server/index.js",
    "test": "jest",
    "lint": "eslint ."
  },
  "dependencies": {
    "express": "^4.18.2",
    "mongoose": "^7.0.3",
    "cors": "^2.8.5",
    "dotenv": "^16.0.3"
  },
  "devDependencies": {
    "nodemon": "^3.0.1",
    "jest": "^29.5.0",
    "eslint": "^8.38.0"
  },
  "engines": { "node": ">=18.0.0" },
  "license": "MIT"
}

πŸ‹οΈ Exercise 2: Resolve a React clash

Goal: Your app pins react@18.2.0. Library A declares a peer dependency on react@^17.0.0, Library B on react@^18.0.0. Diagnose the conflict and propose the cleanest fix.

βœ… Solution

Run npm ls react to confirm the clash β€” Library A's peer range (^17) excludes your 18.2.0, so npm emits a peer-dependency warning.

Best fix: upgrade Library A to a version that supports React 18 (check its releases). If none exists, you can quiet the warning with --legacy-peer-deps or an overrides entry β€” but only after verifying Library A actually works under React 18, since its author hasn't promised it does.

🎯 Quick Quiz

Question 1: A React component library should list react as a…

Question 2: What does the "files" array control?

Question 3: Which command explains why a particular package is installed?

Best Practices & Pitfalls

βœ… Do

  • Put every package in the correct dependency bucket β€” production stays lean
  • Declare shared singletons (React, a plugin's host) as peerDependencies
  • Use a files whitelist so you publish only build output, not source
  • Set "private": true on any repo you never intend to publish
  • Add an engines field so contributors know the required Node version

❌ Don't

  • Use "*" or "latest" as a version β€” builds become unpredictable
  • Reach for overrides before trying to update the offending package
  • Write inline VAR=value in scripts without cross-env β€” it breaks on Windows
  • Hand-edit the lockfile to "fix" a conflict β€” resolve it in package.json and reinstall
  • Dump test and build tools into dependencies β€” they belong in devDependencies

Summary

πŸŽ‰ Key Takeaways

  • package.json organizes into metadata, dependencies, scripts, and configuration
  • There are four dependency types; the bucket decides when β€” and whether β€” a package installs
  • peerDependencies ask the host app to supply a shared package, preventing duplicate copies
  • Version constraints trade freedom for stability: ^ > ~ > exact
  • Resolve conflicts with npm ls/npm explain first, and overrides only as a last resort

πŸ“š Additional Resources

πŸš€ What's Next?

You can now read and author any manifest. In the next lesson you flip roles β€” from consumer to author β€” and build, document, test, and ship your own package in Creating and Publishing Packages.

πŸŽ‰ Manifest mastered!

The file that used to look like boilerplate is now a map you can read fluently β€” for any project on the planet.