Logo Gerardo Perrucci - Full Stack Developer

Practical Software Versioning—and exactly how package.json ranges work

Practical Software Versioning and package.json ranges

Software versioning is how we encode change. Do it well and you get predictable upgrades, safe rollouts, and crisp communication across teams. Do it poorly and you ship surprises. Below is a clear, example-driven guide to common versioning schemes and the precise meaning of npm's range syntax in package.json.

Table of Contents

Versioning schemes you'll meet in the wild

Semantic Versioning (SemVer)

Format: MAJOR.MINOR.PATCH[-pre][+build], e.g., 2.4.1, 3.0.0-rc.1.

  • MAJOR: breaking changes.
  • MINOR: new, backward-compatible features.
  • PATCH: backward-compatible fixes.
  • Pre-release (-alpha, -beta, -rc.1): release candidates or experimental builds.
  • Special rule for 0.x: treat everything as unstable—any MINOR bump can be breaking (0.2.0 0.3.0 may break).

Calendar Versioning (CalVer)

Encodes time instead of compatibility, e.g., Ubuntu 24.04 (YYYY.MM). Great when cadence and freshness matter more than API promises (platforms, CLIs, distros).

Monotonic "marketing" majors

Some products just bump the major (e.g., browsers) to keep pace with train releases. It's simple and avoids over-promising compatibility, but consumers must read changelogs carefully.

When to pick what

  • Libraries that promise an API: prefer SemVer.
  • Apps/CLIs/distros on a fixed train: CalVer or simple majors work well.
  • Early stages (pre-1.0.0): use 0.x and assume instability; communicate loudly.
Decorative quote icon

SemVer (from the spec): "Given a version number MAJOR.MINOR.PATCH, increment the MAJOR version when you make incompatible API changes, the MINOR version when you add functionality in a backwards compatible manner, and the PATCH version when you make backwards compatible bug fixes."semver.org

The exact meaning of version ranges in package.json

npm (and Yarn/pnpm) use the node-semver rules. Here's what each form means, with concrete examples you can reason about.

Exact pin

"react": "18.2.0"

Only 18.2.0 satisfies. No updates unless you edit the file (or use overrides/resolutions).

Caret ^ (safe by major)

"react": "^18.2.0" → allows >=18.2.0 <19.0.0.

You'll get 18.3.0 or 18.2.1 automatically, never 19.0.0.

Special case for 0.x: ^0.2.3>=0.2.3 <0.3.0 and ^0.0.3>=0.0.3 <0.0.4

Tilde ~ (safe by minor)

"react": "~18.2.0">=18.2.0 <18.3.0.

Patch updates only (e.g., 18.2.8), no minor bump.

X-ranges & wildcards

  • "1.2.x">=1.2.0 <1.3.0
  • "1.x">=1.0.0 <2.0.0
  • "*" → any version (don't).

Comparator ranges

  • ">=1.2.3 <2.0.0": explicit range, same spirit as ^1.2.3 but more precise.
  • "1.2.3 - 1.4.5": hyphen range, equivalent to >=1.2.3 <=1.4.5.

Pre-releases

  • "^1.2.3" does not include 1.3.0-rc.1. Pre-releases are excluded unless your range also mentions a pre-release or you install with prerelease flags.
  • "^1.2.3-rc.0">=1.2.3-rc.0 <1.3.0 (includes subsequent RCs and the final 1.2.3).
  • "my-lib": "workspace:*" or "file:../my-lib" to consume a local package during monorepo development.

Transitives and enforcement

  • overrides (npm) / resolutions (Yarn) force a specific version of a transitive dep.
  • engines sets runtime constraints:
"engines": { "node": ">=18.17" }

Concrete package.json examples

Here's what different range declarations actually allow in practice:

{
  "name": "versioning-demo",
  "private": true,
  "engines": { "node": ">=18.17" },
  "dependencies": {
    "react": "^18.2.0",          // >=18.2.0 <19
    "axios": "~1.7.2",           // >=1.7.2 <1.8.0
    "zod": "3.23.8",             // exactly 3.23.8
    "vite": "5.x",               // >=5.0.0 <6.0.0
    "some-lib": ">=2.4 <3"       // explicit comparator range
  },
  "devDependencies": {
    "typescript": "^5.6.3",      // >=5.6.3 <6
    "@types/node": "20.11.x"     // >=20.11.0 <20.12.0
  },
  "overrides": {
    "transitive-buggy-lib": "4.1.2" // force this version everywhere
  }
}

Important npm behaviors

  • npm install somepkg@1.2.3 will, by default, write ^1.2.3 into package.json unless you set save-exact=true.
  • package-lock.json (or yarn.lock/pnpm-lock.yaml) pins the full tree so builds are reproducible.
  • npm ci installs exactly what the lockfile says and fails if it's out of sync. Use in CI.
  • npm update moves deps to the latest versions that still satisfy the range.

Quick mental model: choose ranges by context

Applications: prefer ^ for most deps, but rely on the lockfile for determinism; use ~ for ultra-sensitive packages.

Libraries: set peerDependencies to the broadest compatible range (e.g., react: ">=18 <20"), keep direct deps with ^ or comparators, and publish breaking changes only on MAJOR bumps.

Pre-1.0: communicate loudly; consider exact pins or narrow ranges because any change can be breaking.

Pitfalls to avoid

  • Accidentally pulling a breaking transitive: broad ranges without a lockfile can upgrade under your feet. Use the lockfile and consider overrides.
  • Misreading 0.x: ^0.2.3 does not allow 0.3.0.
  • Assuming pre-releases are picked: ranges exclude them unless the range includes a pre-release tag.
  • Forgetting peerDependencies: framework plugins should declare peers (e.g., React, Vite) so the host app controls the version.

Runnable TypeScript: validate versions like npm

Install the helper first:

npm i semver

Now a tiny script you can run with ts-node (or compile with tsc):

// check-range.ts
import { satisfies, minVersion, coerce } from "semver";

// Prints whether a version fits a range and the minimal version for that range.
function check(range: string, version: string) {
  const ok = satisfies(version, range);
  const min = minVersion(range)?.version ?? "unknown";
  console.log(`${version} ${ok ? "" : ""} ${range}  |  min allowed = ${min}`);
}

// Examples
check("^18.2.0", "18.2.5");     // true
check("^18.2.0", "19.0.0");     // false
check("~1.7.2", "1.7.9");       // true
check("~1.7.2", "1.8.0");       // false
check("^0.2.3", "0.2.9");       // true
check("^0.2.3", "0.3.0");       // false

// Pre-release behavior
check("^1.2.3", "1.3.0-rc.1");  // false
check("^1.2.3-rc.0", "1.2.3-rc.2"); // true
check(">=1.2.3-0", "1.3.0-rc.1");   // true

// Helpful: coerce loose tags like "v1.2"
console.log("coerce('v1.2') ->", coerce("v1.2")?.version); // 1.2.0

This mirrors the same semantics npm uses under the hood.

Compatibility signals to look for

  • Backwards compatible: MINOR or PATCH bump under SemVer.
  • Potentially breaking: MAJOR bump, or any bump in 0.x.
  • Forward compatibility: formats or protocols that let new producers still be handled by old consumers (usually via versioned feature flags). Document these explicitly in your API.

Use-case playbook

  • Internal product: pin via lockfile, use ^ for convenience, ship with changelogs and smoke tests on npm update.
  • Published runtime library: broad peers, narrow direct deps, CI matrix across supported host versions.
  • CLI tooling: CalVer or frequent majors; treat breaking flags as MAJOR changes even if code is small.

References

Summary

Software versioning is critical for managing dependencies and communicating changes. Semantic Versioning (SemVer) is the de facto standard for libraries, using MAJOR.MINOR.PATCH to signal breaking changes, new features, and fixes. Calendar Versioning (CalVer) works better for platforms and tools where release cadence matters more than API stability.

Understanding npm's range syntax is essential: ^1.2.3 allows minor and patch updates (>=1.2.3 <2.0.0), while ~1.2.3 only permits patches (>=1.2.3 <1.3.0). Special rules apply to 0.x versions, where caret ranges are much more restrictive, and pre-release versions are excluded from ranges unless explicitly included.

For applications, use caret ranges with lockfiles for reproducibility. For libraries, set broad peerDependencies and break APIs only on major bumps. Always use npm ci in CI/CD, leverage overrides for problematic transitives, and keep dependencies updated with automated tools like Renovate or Dependabot.

Pro tip for teams: set save-exact=true for ultra-predictable apps, keep Renovate/Dependabot running, and only widen ranges when tests prove it's safe.