AlgoInsight

Free lesson · JavaScript Basic to Advance

npm, package.json, and the Module Ecosystem

Easy5 min read

How JavaScript Code Gets Shared

A module is one file. A package is a directory of related modules, plus metadata describing how they fit together. npm (originally "Node Package Manager," now just "npm") is the registry where packages live and the command-line tool that installs them.

$ npm init -y                          # create a package.json
$ npm install lodash                   # install a dependency
$ npm install --save-dev jest          # install a dev-only dependency
$ npm run build                         # run a defined script
// Now you can use the package in code
import _ from "lodash";

console.log(_.chunk([1, 2, 3, 4, 5, 6], 2));

When you npm install lodash, npm downloads the package from the registry, places it in node_modules/lodash, and records the dependency in package.json. Your code can then import from lodash (no path needed) because Node's module resolution finds it in node_modules.

This Topic covers the practical pieces: what's in package.json, how dependencies work, how imports resolve to packages, and the conventions you'll encounter as you work with the ecosystem.

What package.json Contains

The package.json file is the metadata for a JavaScript project. Every Node project has one. Every published npm package has one.

{
  "name": "my-app",
  "version": "1.0.0",
  "description": "My application",
  "type": "module",
  "main": "./src/index.js",
  "scripts": {
    "start": "node src/index.js",
    "build": "esbuild src/index.js --bundle --outfile=dist/bundle.js",
    "test": "jest"
  },
  "dependencies": {
    "lodash": "^4.17.21",
    "express": "^4.19.0"
  },
  "devDependencies": {
    "jest": "^29.0.0",
    "esbuild": "^0.20.0"
  }
}

Each field has a specific purpose:

name and version. Together these uniquely identify a package. Versions follow semantic versioning — MAJOR.MINOR.PATCH where major changes break the API, minor add backwards-compatible features, and patch fixes bugs.

type. Determines whether .js files in this package are ES Modules or CommonJS. "module" means ES Modules; absence or "commonjs" means CommonJS. This affects how the whole package's .js files are interpreted.

main. The entry point — the file that gets loaded when someone imports the package by name. import lodash from "lodash" loads whatever main points to.

scripts. Named shell commands you can run with npm run <name>. The conventional names — start, build, test, dev — are common across projects.

dependencies. Packages your code needs at runtime. These get installed when consumers install your package.

devDependencies. Packages needed only during development (test runners, bundlers, linters). Not installed when consumers install your package.

There are many other optional fields — exports (modern way to define entry points), repository, license, keywords, engines, etc. The fields above are the ones you encounter constantly.

Version Ranges and Lockfiles

Interactive animation: What each spelling accepts - an exact version, the ~ range and the ^ range, traced against MAJOR.MINOR.PATCH.

When package.json says "lodash": "^4.17.21", that's a version range — it means "any 4.x.y where y is at least 21 and x is at least 17." The ^ (caret) is the most common range: it allows minor and patch updates but not major version bumps.

Other range syntaxes:

The range tells npm what's acceptable. When you actually run npm install, the exact resolved versions get written to a lockfile — package-lock.json (or pnpm-lock.yaml, or yarn.lock depending on your package manager).

The lockfile ensures reproducible installs. Without it, different developers on the same project could end up with subtly different dependency versions and hard-to-reproduce bugs. Always commit your lockfile to version control.

Interactive animation: Intent versus truth - package.json holds the ranges, the lockfile records the exact versions that were installed.

How Bare Imports Resolve

When you write import x from "lodash", the runtime has to figure out where lodash lives. The algorithm (for Node.js) walks up the directory tree:

  1. Look in ./node_modules/lodash/
  2. If not found, look in ../node_modules/lodash/
  3. If not found, look in ../../node_modules/lodash/
  4. Continue up to the filesystem root

This is how nested projects work. A monorepo with packages/foo and packages/bar can each have their own node_modules directories, and packages can also be hoisted to a root node_modules shared between them.

Once found, the runtime reads the package's package.json to determine the entry point — typically main or exports (for modern packages with conditional exports).

graph TD
    A[import x from lodash] --> B[Look in ./node_modules/lodash]
    B -->|not found| C[Look in ../node_modules/lodash]
    C -->|not found| D[Look in ../../node_modules/lodash]
    D -->|not found| E[Continue up to root]
    B -->|found| F[Read package.json]
    F --> G[Resolve to main entry point]
    G --> H[Load that file]

    style A fill:#10b981,color:#fff
    style F fill:#7c3aed,color:#fff
    style H fill:#3b82f6,color:#fff
This diagram shows Node's module resolution algorithm. When you import a bare specifier like lodash, Node starts by looking in the closest node_modules directory. If not found, it looks in the parent directory's node_modules, then the grandparent's, walking up the filesystem until either the package is found or the root is reached. Once found, Node reads the package's package.json file, finds the entry point specified by the main or exports field, and loads that specific file.

This is also why "duplicate dependencies" in node_modules can exist — different packages might depend on different versions of the same library, each getting its own copy in nested node_modules directories.

Package Managers: npm, yarn, pnpm

npm is the original tool, bundled with Node.js. Yarn (created by Facebook) and pnpm (created by Zoltan Kochan) are alternatives that solve specific problems with npm.

Practical differences:

npm — the default. Reliable, ubiquitous, increasingly fast in recent versions. Use this if you don't have a specific reason for something else.

yarn — emphasizes performance and consistency. Yarn workspaces are popular for monorepos. Different lockfile format (yarn.lock).

pnpm — uses a global content-addressable store and symlinks, drastically reducing disk usage in projects with many dependencies. Faster installs. Different lockfile (pnpm-lock.yaml).

For most projects, the choice doesn't matter much. Modern teams often standardize on one to ensure consistent installs across developers. My Microsoft team uses npm for most projects; some other teams I've worked with use pnpm specifically for the disk efficiency in large monorepos.

Publishing Your Own Package

If you want to share a package on the public npm registry:

  1. Create a package.json with name, version, and a unique name
  2. Write your code in the configured entry point
  3. Create an account: npm adduser
  4. Publish: npm publish

The name must be unique on the registry. Names with @ are scoped — @your-org/package-name — and useful for keeping organizational packages grouped.

A few publishing realities to know:

You can never republish the same version. Once 1.0.0 is published, you can't replace it. You'd need to bump to 1.0.1 and publish that.

You can unpublish within 72 hours. After that, unpublishing requires npm support and is heavily restricted (because removed packages can break consumer projects).

Private packages require a paid npm plan or a private registry. GitHub Packages, npm Pro, or self-hosted Verdaccio are common options.

The "Wait, This Pulls in How Many Packages?" Problem

A surprising aspect of the npm ecosystem: even small packages can pull in dozens or hundreds of transitive dependencies. A simple express install brings in 50+ packages. A typical React app's node_modules directory can hit hundreds of megabytes and tens of thousands of files.

This isn't a bug — it's a feature of the "small modules" culture in JavaScript. The result is that you've installed code from many authors, any of whom could have a security issue or compatibility break.

Tools that help:

Production teams typically combine these with policies — periodic dependency reviews, security scanners in CI, restricted lists of allowed packages for sensitive projects.

Common Pitfalls

Warning: The npm ecosystem traps.

Not committing the lockfile. Without package-lock.json (or equivalent), every install can produce a different node_modules. Always commit lockfiles.

Confusing dependencies and devDependencies. Test frameworks, bundlers, linters belong in devDependencies. Anything your runtime code uses belongs in dependencies. Getting this wrong means consumers install unnecessary packages.

Using * or latest as a version range. This is a recipe for breakage. Pin versions or use sensible ranges (^ is reasonable for most cases).

Installing global packages for project tools. Tools like jest or eslint should be project dependencies, not global installs. Otherwise different projects' team members might have different tool versions and inconsistent behavior.

Why This Matters in Interviews

Interview: npm questions appear at all interview levels but rarely as the main topic. "What's the difference between dependencies and devDependencies?" is a basic question. "How does Node resolve a bare import like lodash?" is intermediate. "Why does npm install produce different results on different machines without a lockfile?" is the kind of question that probes whether you've actually shipped code.

Topic 11.7 — Interview Lens: Module System Questions — covers the deeper treatment, including the ecosystem questions interviewers ask at senior levels.

Try It

Exercise: Initialize a small project from scratch:

  1. mkdir my-project && cd my-project
  2. npm init -y — creates a basic package.json
  3. Add "type": "module" to your package.json
  4. npm install lodash — observe what changes in package.json and node_modules
  5. Write a small index.js that imports from lodash and runs it with node index.js
  6. Open package-lock.json — see what's in there. Notice the exact versions and integrity hashes.
  7. Add a custom script in package.json's scripts field, run it with npm run

By the time this works, you've used every part of the basic npm workflow. The rest is variations on these patterns.

Back to JavaScript Basic to Advance