JAVASCRIPT / MODULES AND DEVELOPER TOOLING
Semantic versioning and lockfiles
Read npm version ranges as sets of allowed releases, and use a lockfile plus npm ci to make an install reproduce the same dependency tree every time.
What you will learn
- Read ^1.2.3 and ~1.2.3 as sets of releases and name what each one excludes
- Explain why ^0.4.1 rejects 0.5.0 but ^4.1.0 accepts 4.9.3
- Use npm ci to install the exact tree in the lockfile instead of re-resolving
- Commit the lockfile with your app and know what its integrity hashes protect
Understanding Semantic versioning and lockfiles
A version like 4.17.21 is three separate claims from the publisher: a patch bump (21) says bug fixes only, a minor bump (17) says new features that do not break existing calls, and a major bump (4) says existing code may stop working. What sits in package.json is rarely a single version, though; it is a range. The string ^4.17.19 means any release from 4.17.19 up to but not including 5.0.0, while ~4.17.19 narrows that to the 4.17.x line, so each range describes a set of acceptable versions. At install time the resolver picks the highest published member of that set, which is why one unchanged package.json can produce different trees on Monday and on Friday.
Caret ranges do not simply pin the major number; they keep the leftmost non-zero number fixed. That is why ^4.1.0 happily moves to 4.9.3 while ^0.4.1 refuses 0.5.0: a package below 1.0.0 has not committed to a stable API, so npm treats its minor number with the caution normally reserved for the major. This matters in practice because plenty of tooling stays at 0.x for years, and a range that looks permissive is really pinning you to one minor line.
A lockfile is a different kind of file: not a policy but a record of one past resolution. For every package in the tree, including transitive dependencies you never named, it stores the exact version, the tarball it came from, an integrity hash, and where in node_modules it was placed, including the nested duplicates that appear when two dependents need incompatible majors of the same package. Running npm install is allowed to move those pins forward inside your ranges, whereas npm ci reads the lockfile as its input and rebuilds exactly that tree, failing if package.json and the lockfile disagree. Commit the lockfile with an application so that a fresh clone and a CI run install the same bytes you tested.
// A range is a set of versions. This implements the rule npm uses for ^ and ~.
const parts = (v) => v.split('.').map(Number);
function compare(a, b) {
const [aMaj, aMin, aPatch] = parts(a);
const [bMaj, bMin, bPatch] = parts(b);
return aMaj - bMaj || aMin - bMin || aPatch - bPatch;
}
function satisfies(version, range) {
const op = range[0]; // '^' or '~'
const base = range.slice(1); // the floor of the range
if (compare(version, base) < 0) return false;
const [baseMaj, baseMin] = parts(base);
const [vMaj, vMin] = parts(version);
if (op === '~') return vMaj === baseMaj && vMin === baseMin;
if (baseMaj !== 0) return vMaj === baseMaj; // caret pins the major
return vMaj === 0 && vMin === baseMin; // ...unless the major is 0
}
for (const v of ['1.2.2', '1.2.3', '1.2.9', '1.4.0', '2.0.0']) {
console.log(v, '^1.2.3 ->', satisfies(v, '^1.2.3'), '| ~1.2.3 ->', satisfies(v, '~1.2.3'));
}
console.log('0.2.5', '^0.2.3 ->', satisfies('0.2.5', '^0.2.3'));
console.log('0.3.0', '^0.2.3 ->', satisfies('0.3.0', '^0.2.3'));package.json declares which versions you are willing to accept, while the lockfile records which ones you actually installed, so reproducibility comes from the second file rather than the first.
Worked examples
Why an unchanged range drifts
The same caret range resolves to a different version once the registry gains a new release, unless a lockfile decides for you.
const compare = (a, b) => {
const [x, y, z] = a.split('.').map(Number);
const [p, q, r] = b.split('.').map(Number);
return x - p || y - q || z - r;
};
// '^4.17.19' means: at least 4.17.19, still major 4
const inRange = (v, floor) =>
compare(v, floor) >= 0 && v.split('.')[0] === floor.split('.')[0];
const resolve = (registry, floor) =>
registry.filter((v) => inRange(v, floor)).sort(compare).pop();
const monday = ['4.17.19', '4.17.20'];
const friday = ['4.17.19', '4.17.20', '4.17.21', '5.0.0'];
const lock = { 'node_modules/acme': { version: '4.17.20' } };
console.log('Monday install ->', resolve(monday, '4.17.19'));
console.log('Friday install ->', resolve(friday, '4.17.19'));
console.log('With lockfile ->', lock['node_modules/acme'].version);Example explained
Line 1resolve filters the registry down to the range, sorts, and takes the last item: highest match wins, which is what npm install does when nothing pins the choice.
Line 2Friday's registry contains 4.17.21, so an identical range yields a version that did not exist on Monday.
Line 35.0.0 passes the compare check but fails the major test, so the caret excludes it.
Line 4The last line reads a version out of a recorded tree instead of the registry, so the answer no longer depends on the date.
Version order is not string order
Sorting versions as plain strings puts 1.10.0 before 1.2.0, and prereleases are excluded from ordinary ranges.
const bySemver = (a, b) => {
const [aMaj, aMin, aPatch] = a.split('.').map(Number);
const [bMaj, bMin, bPatch] = b.split('.').map(Number);
return aMaj - bMaj || aMin - bMin || aPatch - bPatch;
};
const versions = ['1.9.0', '1.10.0', '1.2.0'];
console.log('lexicographic:', [...versions].sort().join(' < '));
console.log('semver order: ', [...versions].sort(bySemver).join(' < '));
const candidates = ['2.0.0-rc.1', '1.9.0', '1.10.0'];
const stable = candidates.filter((v) => !v.includes('-'));
console.log('stable only: ', stable.sort(bySemver).join(' < '));Example explained
Line 1Array.prototype.sort with no comparator compares strings, and '1.1' sorts before '1.2', so 1.10.0 looks older than 1.2.0.
Line 2bySemver compares the three numbers in order, and || short-circuits on the first non-zero difference, so minor only breaks a major tie.
Line 32.0.0-rc.1 is filtered out because a prerelease sorts below its own release and never matches a plain range; you have to ask for it explicitly.
One package, two pinned copies
Lockfile entries are keyed by install path, which is how two incompatible majors of the same package coexist reproducibly.
// A lockfile is keyed by install path, not by package name.
const lockfile = {
'node_modules/parser': { version: '3.4.1', wantedBy: 'app wants ^3.0.0' },
'node_modules/plugin': { version: '1.0.0', wantedBy: 'app wants ^1.0.0' },
'node_modules/plugin/node_modules/parser': { version: '2.9.0', wantedBy: 'plugin wants ^2.8.0' }
};
for (const [path, entry] of Object.entries(lockfile)) {
console.log(`${entry.version} ${path} (${entry.wantedBy})`);
}
const parsers = Object.keys(lockfile).filter((p) => p.endsWith('/parser'));
console.log(`parser appears ${parsers.length} times, and both places are pinned`);Example explained
Line 1The keys are directory paths, so the same package name can appear more than once with different versions.
Line 2The nested copy exists because ^3.0.0 and ^2.8.0 share no version, so no single install can satisfy both dependents.
Line 3Both entries carry their own pin, so the duplication is reproduced identically everywhere rather than collapsing differently per machine.
Important notes
Semver is a promise, not an enforcement mechanism; a publisher can ship a breaking change in a patch, which is why a pinned lockfile plus a test run is the real safety net.
A lockfile governs only the project it belongs to: if you publish a library, consumers ignore your lockfile and resolve your declared ranges themselves.
Common mistakes
Putting the lockfile in .gitignore: every machine and every CI run resolves the ranges on its own clock, so a failure can appear in CI that nobody can reproduce locally.
Bumping a version inside package.json by hand without reinstalling: the lockfile still pins the old resolution, so npm ci aborts on the mismatch and npm install may quietly place something other than what you typed.
Reading ^0.7.2 as 'any 0.x': it stops before 0.8.0, so an upgrade you believe is arriving automatically never actually installs.
Try it yourself
Change, predict, then run
In a browser editor, run the satisfies function from above over ['2.3.3', '2.3.4', '2.3.11', '2.4.0', '3.0.0'] for both ~2.3.4 and ^2.3.4, and print only the versions where the two answers differ.
Open the JavaScript workspaceCheck your understanding
A project's package.json asks for "chart-lib": "^2.4.0" and its lockfile pins 2.4.1. The publisher then releases 2.9.0 and 3.0.0. A colleague clones the repo and runs npm ci. Which version ends up in node_modules?
- 2.9.0, because a caret range always resolves to the newest compatible release
- 3.0.0, because npm ci ignores ranges and takes the latest published version
- 2.4.1, because npm ci installs the tree recorded in the lockfile
- 2.4.0, because the floor of the range is used whenever a lockfile is present
Show answer
npm ci treats the lockfile as its input and rebuilds the recorded tree, so the clone gets 2.4.1. Option 0 is tempting because 2.9.0 is the highest release the caret admits, but that is what a fresh npm install with no lockfile would choose; the lockfile exists precisely so the result does not depend on what has been published since.