Semantic Versioning and Dependencies

When you install a package, you are depending on a specific version of someone else’s code.

Understanding Semantic Versioning

Most packages follow semantic versioning (often called semver), which uses the format MAJOR.MINOR.PATCH:

  • MAJOR – incremented for breaking changes (incompatible API changes)
  • MINOR – incremented for new features that are backwards compatible
  • PATCH – incremented for backwards-compatible bug fixes

For example, a version bump from 2.3.1 to 2.4.0 means new features are added but nothing should break. A jump from 2.4.0 to 3.0.0 means something changes in a way that could break your code.

Version Ranges in package.json

When you install a package, npm records it with a version range rather than a fixed version. This range controls what updates are allowed:

Symbol Meaning Example Matches
(none) Exact version only 1.2.3 Only 1.2.3
^ Compatible with version ^1.2.3 >=1.2.3 and <2.0.0
~ Approximately equivalent ~1.2.3 >=1.2.3 and <1.3.0
> Greater than >1.2.3 Any version above 1.2.3
>= Greater than or equal >=1.2.3 1.2.3 or higher
* Any version * Anything at all

The ^ (caret) is the default when you run npm install. It allows minor and patch updates but blocks major version bumps, so you get bug fixes without breaking changes.

The ~ (tilde) is more conservative. It only allows patch-level updates.

Lock Files and Reproducible Builds

The problem is that version ranges like ^1.2.3 can resolve to different actual versions depending on when you run npm install. Your machine might get 1.2.3 today, and a teammate might get 1.2.7 next week.

That is what package-lock.json is for. It records the exact version of every package (and every transitive dependency) that was installed. When someone else runs npm install, npm reads the lock file and installs those exact versions.

Always commit your lock file to version control. It ensures everyone on the team – and your CI/CD pipeline – gets identical dependencies.

Common package.json Fields

Beyond dependencies and scripts, package.json has several other useful fields:

{
  "name": "my-package",
  "version": "1.0.0",
  "description": "A brief description of the project",
  "main": "dist/index.cjs",
  "exports": {
    ".": {
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs"
    }
  },
  "types": "dist/index.d.ts",
  "files": ["dist"],
  "engines": {
    "node": ">=18.0.0"
  },
  "keywords": ["utility"],
  "author": "Your Name",
  "license": "MIT"
}
  • main – legacy/default CommonJS entry point (used by require())
  • exports – modern, explicit package entry points (supports import/require conditions)
  • types – points to TypeScript type declarations
  • files – which files to include when publishing the package
  • engines – specifies required Node.js version

Running Packages with npx

Sometimes you want to run a CLI tool without installing it globally. That is what npx (an alias for npm exec) is for – it runs a local binary if available, or fetches one to npm’s cache and executes it:

# Scaffold a new Vite app
npx create-vite@latest my-app

# Run a specific version of a tool
npx eslint@latest --init

# Run a locally installed binary
npx jest

npx is useful for one-off tools like project scaffolding or migration utilities.