1.2.3 is not a decimal number and it is not a string. It is three independent counters with a promise attached to each one. Bump the patch and you promised nothing visible changed. Bump the minor and you promised you only added. Bump the major and you admitted you broke something.
A range like ^1.2.3 is you saying "I will take any promise up to the next break". Everything a package manager does with versions falls out of two functions: a comparator that puts two versions in order, and a desugarer that turns friendly range syntax into plain >= / < bounds.
Here is the whole engine, no library.
Parse strictly, because a regex is only half the rules
The grammar is major.minor.patch, then an optional -prerelease, then an optional +build. One regex splits the five parts. Two rules still have to be enforced by hand.
const NUMERIC = /^(0|[1-9]\d*)$/; // no leading zeros
const IDENT = /^[0-9A-Za-z-]+$/; // the only legal charset
function parseSemver(text){
const raw = String(text).trim();
const m = raw.match(/^(\d+)\.(\d+)\.(\d+)(?:-([^+]*))?(?:\+(.*))?$/);
if (!m) return { error: "expected major.minor.patch[-prerelease][+build]" };
for (const [i, name] of [[1,"major"],[2,"minor"],[3,"patch"]])
if (!NUMERIC.test(m[i])) return { error: name + " has a leading zero" };
const pre = m[4] === undefined ? [] : m[4].split(".");
const build = m[5] === undefined ? [] : m[5].split(".");
return { major:+m[1], minor:+m[2], patch:+m[3], pre, build };
}
Numeric fields must not carry leading zeros — if 01 were legal, 1.01.0 and 1.1.0 would be two spellings of one release, and a registry would have to pick which is real. Identifiers may only contain [0-9A-Za-z-] and none may be empty, so a trailing dot is rejected instead of silently producing an empty identifier that sorts below everything.
Note that pre and build are kept as arrays of identifiers, never as one string. Every later rule walks them one at a time.
One identifier: numeric first, then ASCII
Spec §11.4 says digits-only identifiers compare numerically, identifiers with letters compare in ASCII order, and a numeric identifier always ranks lower than an alphanumeric one.
function cmpIdent(a, b){
const an = NUMERIC.test(a), bn = NUMERIC.test(b);
if (an && bn) { // both numeric
if (a.length !== b.length) return a.length < b.length ? -1 : 1;
return a < b ? -1 : a > b ? 1 : 0; // exact, no float
}
if (an) return -1; // numeric < alphanumeric
if (bn) return 1;
return a < b ? -1 : a > b ? 1 : 0; // ASCII order
}
Because leading zeros are already banned, numeric comparison never needs Number(): the longer digit string is the bigger number, and equal lengths compare lexically. That stays exact past 2⁵³, which parseInt does not.
This is where beta.11 > beta.2 comes from — and, less happily, where RC < alpha comes from, since uppercase letters sort first in ASCII.
The prerelease tail: two rules that look backwards
function cmpPre(a, b){
if (!a.length && !b.length) return 0;
if (!a.length) return 1; // 1.0.0 is GREATER than 1.0.0-alpha
if (!b.length) return -1;
const n = Math.min(a.length, b.length);
for (let i = 0; i < n; i++) {
const c = cmpIdent(a[i], b[i]);
if (c !== 0) return c;
}
return a.length === b.length ? 0 : (a.length < b.length ? -1 : 1);
}
Having a prerelease makes a version smaller: 1.0.0-rc.1 is on the way to 1.0.0, so it must sort below it. Get that backwards and every -rc you publish looks newer than the release that follows — which is exactly the bug that makes an auto-updater sit on a beta forever.
Equal prefix, longer wins: alpha < alpha.1, because more identifiers means further along.
Both rules together produce the canonical §11 chain, and it is worth asserting verbatim:
1.0.0-alpha < 1.0.0-alpha.1 < 1.0.0-alpha.beta < 1.0.0-beta
< 1.0.0-beta.2 < 1.0.0-beta.11 < 1.0.0-rc.1 < 1.0.0
The jump from alpha.1 to alpha.beta is not the length rule — it is cmpIdent("1","beta"), numeric losing to alphanumeric on the second identifier before length is ever consulted.
The full comparator, and the field that is never read
function compareVer(a, b){
if (a.major !== b.major) return a.major < b.major ? -1 : 1;
if (a.minor !== b.minor) return a.minor < b.minor ? -1 : 1;
if (a.patch !== b.patch) return a.patch < b.patch ? -1 : 1;
return cmpPre(a.pre, b.pre); // build[] is never looked at
}
Build metadata is absent on purpose. The spec says it MUST be ignored for precedence, which has a consequence people find shocking: 1.0.0+build.1 and 1.0.0+build.2 are equal, not merely close. Registries treat the second upload as a duplicate and refuse it. Metadata can label a build; it can never distinguish a release.
Hand compareVer to Array.prototype.sort and you have a correct release history for free — including 0.9.0 < 0.10.0, which string sorting and parseFloat both get wrong.
Every range is sugar
Split the whole string on || to get OR-rows. Inside a row, whitespace means AND. Each token expands into one or two plain comparators. After that, "does this version satisfy this range" has no special cases left.
Caret is the interesting one. One sentence generates all five cases: the caret pins the leftmost non-zero field.
function caretRange(q){ // ^q
const low = ge(q.M, q.m ?? 0, q.p ?? 0, q.p === null ? [] : q.pre);
let hi;
if (q.M > 0) hi = lt(q.M + 1, 0, 0); // ^1.2.3 -> <2.0.0
else if (q.m === null) hi = lt(1, 0, 0); // ^0.x -> <1.0.0
else if (q.m > 0) hi = lt(0, q.m + 1, 0); // ^0.2.3 -> <0.3.0
else if (q.p === null) hi = lt(0, 1, 0); // ^0.0.x -> <0.1.0
else hi = lt(0, 0, q.p + 1); // ^0.0.3 -> <0.0.4
return [low, hi];
}
That is why ^0.0.3 allows literally nothing but 0.0.3. Below 1.0.0 the spec grants no compatibility promise at all, so shipping 1.0.0 is a real event: it is the moment the caret starts allowing minor updates and your users can stop pinning.
X-ranges are windows, and the operator picks which edge you land on:
">1.2" -> >=1.3.0 // past the whole window
"<=1.2" -> <1.3.0 // through the end of it
"1.2" -> >=1.2.0 <1.3.0 // the window itself
Hyphen ranges use the same logic on the right side: 1.2.3 - 2.3.4 is inclusive at both ends, but 1.2.3 - 2.3 ends at <2.4.0, because 2.3 is a window and not a point.
The prerelease trap
This is the rule that generates the bug reports. 1.3.0-beta.1 sits comfortably between the bounds of ^1.2.3, and it still does not satisfy it.
function satisfiesSet(v, comps, includePrerelease){
if (!comps.every(c => testComparator(v, c))) return false;
if (v.pre.length && !includePrerelease) {
const named = comps.some(c => c.ver.pre.length
&& c.ver.major === v.major
&& c.ver.minor === v.minor
&& c.ver.patch === v.patch);
if (!named) return false;
}
return true;
}
A version carrying a prerelease is only admitted when some comparator in the same AND-row itself names a prerelease on the identical major.minor.patch.
So >=1.3.0-alpha <2.0.0 admits 1.3.0-beta.1. And ^1.2.3-alpha does not, because its prerelease sits at 1.2.3 while the candidate is 1.3.0. You opted into instability for one specific upcoming release, not for every future one. A caret should never silently pull an untested beta into production.
What actually gets installed
A range does not name a version; it names a set. The resolver takes the highest survivor.
function maxSatisfying(list, range, includePrerelease){
let best = null;
for (const v of list) {
if (!satisfies(v, range, includePrerelease)) continue;
if (best === null || compareVer(v, best) > 0) best = v;
}
return best;
}
// published: 1.2.3 1.2.4 1.3.0-rc.1 1.3.0 2.0.0
// "^1.2.3" -> 1.3.0 (the rc is invisible, 2.0.0 is out of bounds)
Which is why two developers installing the same package.json a week apart can legitimately get different code, and why the lockfile — not the manifest — is the reproducible artifact.
Bumping is not always +1
inc("1.2.3", "patch") // 1.2.4
inc("1.2.3-rc.1", "patch") // 1.2.3 <- the rc was already aiming there
inc("1.2.3", "premajor", "beta") // 2.0.0-beta.0
inc("1.2.3-beta.0","prerelease","beta") // 1.2.3-beta.1
Bumping the patch of a prerelease drops the prerelease instead of incrementing anything, because 1.2.3-rc.1 was already a candidate for 1.2.3.
Prove it
The §11 chain, asserted pairwise, catches most mistakes:
const CHAIN = ["1.0.0-alpha","1.0.0-alpha.1","1.0.0-alpha.beta","1.0.0-beta",
"1.0.0-beta.2","1.0.0-beta.11","1.0.0-rc.1","1.0.0"];
for (let i = 1; i < CHAIN.length; i++)
console.assert(compareVer(parseSemver(CHAIN[i-1]), parseSemver(CHAIN[i])) < 0);
console.assert(compareVer(parseSemver("1.0.0+a"), parseSemver("1.0.0+b")) === 0);
A stronger one is worth writing too: enumerate a grid of versions (major 0-3 × minor 0-4 × patch 0-4, each also as -alpha, -alpha.1, -beta.2, -rc.1), then for every range assert satisfies() agrees with a hand-derived comparator set evaluated by a second, independent path. Add antisymmetry and transitivity over random triples, and a subtly inconsistent comparator has nowhere left to hide.
Type a version, type a range, and watch the desugaring happen: https://dev48v.infy.uk/solve/day60-semver-range-tester.html
Top comments (0)