Software Engineering · Dependencies

What a Version Range Selects: The Interval Algebra Behind Caret and Tilde

How comparators, disjunction, and prerelease exclusion turn a version constraint into a concrete set of versions, and where the range language stops describing compatibility at all.

A dependency manifest almost never names a version. It names a predicate. The line "left-pad": "^1.2.3" does not select version 1.2.3; it selects whatever version the resolver finds that satisfies a set of arithmetic conditions, and the difference between those two statements explains most of the surprises in dependency management. Two installations from the same manifest, months apart, can legitimately contain different code.

Understanding what a range means requires treating versions as a totally ordered set and ranges as subsets of it. Semantic Versioning defines the order; the range syntax layered on top of it defines operations over that order. Once the two are separated, the odd corners — prerelease exclusion, the special treatment of major version zero, the interaction of || with everything else — stop looking like quirks and start looking like consequences of a small formal system.

The total order comes from the specification

Semantic Versioning 2.0.0 fixes a version as three dot-separated non-negative integers, optionally followed by a prerelease suffix and a build suffix. Precedence is defined in section 11 of the specification: compare major, then minor, then patch numerically. A version carrying a prerelease suffix has lower precedence than the same triple without one, so 1.0.0-rc.1 precedes 1.0.0. Prerelease identifiers are compared field by field, with numeric identifiers compared as integers and alphanumeric identifiers compared in ASCII lexical order, and a numeric identifier always ranks below an alphanumeric one. Build metadata is excluded from precedence entirely: 1.0.0+build.7 and 1.0.0+build.9 compare equal.

That last rule has a practical consequence that is easy to miss. Because build metadata does not participate in ordering, no range can distinguish two versions that differ only in build metadata. A constraint system built on SemVer precedence therefore cannot select on build numbers, commit identifiers, or anything else that publishers choose to place after the +.

The set of valid versions under this order is discrete, not dense. Between 1.2.3 and 1.2.4 there is no version, only the prereleases of 1.2.4, which sort below it. Discreteness is what makes ranges tractable: every range denotes a finite union of half-open intervals with concrete endpoints, so membership is decidable by comparison, and set intersection is computable without enumerating candidates.

Comparators, and the two abbreviations worth memorising

The range syntax used across the JavaScript ecosystem, and imitated with variations elsewhere, is built from primitive comparators: >, >=, <, <=, =. Each primitive denotes a half-open interval over the version order. A bare version means =, an x or * in a numeric position means “any value in that position”, and a hyphenated pair 1.2.3 - 2.3.4 means >=1.2.3 <=2.3.4, inclusive at both ends.

The caret and tilde forms are abbreviations that expand into primitive comparators, and their expansion depends on the numbers present. Tilde allows patch-level movement: ~1.2.3 expands to >=1.2.3 <1.3.0, while ~1.2 expands to >=1.2.0 <1.3.0 and ~1 to >=1.0.0 <2.0.0. Caret allows movement that does not touch the leftmost nonzero field:

// The caret ceiling is the increment of the first nonzero field from the left.
function caretCeiling([major, minor, patch]) {
  if (major > 0) return [major + 1, 0, 0];   // ^1.2.3 -> <2.0.0
  if (minor > 0) return [0, minor + 1, 0];   // ^0.2.3 -> <0.3.0
  return [0, 0, patch + 1];                  // ^0.0.3 -> <0.0.4
}

So ^1.2.3 is >=1.2.3 <2.0.0, ^0.2.3 is >=0.2.3 <0.3.0, and ^0.0.3 is >=0.0.3 <0.0.4. The same text therefore denotes intervals of wildly different widths depending on where the zeros fall, which is the first place the range language and the compatibility promise diverge.

Disjunction makes a range a set of comparator sets

A range containing || is a union. >=1.0.0 <2.0.0 || >=3.0.0 <4.0.0 accepts two disjoint bands and rejects everything between them. Internally, parsers normalise such a range into disjunctive normal form: an array of clauses, each clause an array of comparators that must all hold. Membership is then a two-level test — any clause satisfied means the range is satisfied — and intersection of two ranges is computed by intersecting every clause of one with every clause of the other.

That product is the cost model to keep in mind. Intersecting two ranges with $m$ and $n$ clauses produces up to $m \times n$ clauses, and simplifying them back down requires reasoning about interval overlap rather than string comparison. In ordinary manifests $m$ and $n$ are one or two, so the cost is invisible. It becomes visible in generated constraints, where a tool emits a long chain of alternatives to encode “any version except these five”.

The useful property is closure: the intersection of two ranges expressible in the language is itself expressible in the language, and emptiness of that intersection is decidable. A resolver does not need to try candidate versions to learn that two requirements are incompatible; it can intersect the constraints and discover the empty set directly. This is why conflict detection is cheap even when conflict resolution is not, a distinction that the resolution search depends on.

Prerelease exclusion is a filter, not an interval

The subtle rule is that a prerelease version does not satisfy a comparator set merely by falling inside the numeric bounds. It satisfies the set only if some comparator in that same clause itself carries a prerelease with an identical major, minor, and patch triple. Hence >=1.0.0 rejects 1.5.0-beta.1, and >=1.5.0-beta.1 accepts 1.5.0-beta.2 and 1.6.0 but still rejects 1.6.0-beta.1, whose triple appears nowhere in the clause.

This cannot be expressed as an interval operation. Interval widening changes bounds; the prerelease rule depends on tuple equality between the comparator and the candidate, which is not a property of the interval at all. Implementations therefore evaluate it as a post-filter applied after containment, clause by clause. The design rationale is that prereleases are opt-in: a publisher marks a version as not yet carrying the compatibility promise, so accepting it silently would violate the manifest author’s intent. The cost is that any project wanting to test against prereleases must name one explicitly in every clause that should admit them, which is why the constraint text for a prerelease-testing setup is noticeably longer than for a stable one.

Major zero is a convention with no specification behind it

SemVer states that major version zero is for initial development and that anything may change at any time. It does not define a compatibility boundary for such versions, because it does not claim there is one. The caret rule supplies one by convention: the leftmost nonzero field is treated as the compatibility boundary, so ^0.2.3 freezes the minor and ^0.0.3 freezes the patch, degenerating into an exact pin.

The effect on upgrade behaviour is severe and asymmetric. A project at 0.x receives essentially no automatic patch-range movement at 0.0.z, a single-minor window at 0.y.z, and a normal window only once it reaches 1.0.0. Dependents that write caret ranges against 0.x libraries must therefore expect to bump their own constraints on nearly every upstream minor release. This is a convention, not a law: other ecosystems map the same textual constraint onto different intervals, and some ecosystems have no range syntax at all, expressing only a minimum acceptable version. A constraint copied between ecosystems does not carry its meaning with it.

What the language cannot say

Three things a manifest author usually wants are outside the range language or awkward inside it.

The compatibility claim itself is unverifiable. A range asserts that everything in the interval is interchangeable, and nothing in the version string enforces it. Publishers can ship a minor release that removes a function, and the range that admitted it will keep admitting it. Conversely, a security fix backported to an older minor is invisible to a range that starts at a later minor: ^1.4.0 excludes 1.3.9, even if 1.3.9 is the only patched release on that line. Ranges express movement, not the set of acceptable states.

Ranges also cannot coordinate with the rest of the graph. “Use the same minor as everything else that depends on this library” is a global property, and a manifest constraint is local by construction. Only the resolver can enforce it, and only if it is written to prefer convergence.

Finally, exclusion of a single bad release requires splitting the interval by hand: >=1.2.3 <1.4.5 || >1.4.5 <2.0.0. This works, and it composes with intersection, but it multiplies clause counts and it must be repeated by every dependent. The alternative is for the registry to mark the release unusable, which is a different mechanism with different semantics, covered under yanking and retraction.

A range is input to resolution, never output. The assignment of concrete versions that a build actually used is recorded elsewhere, and the difference between the two is why a lockfile exists.

References

  1. Semantic Versioning 2.0.0 — semver.org
  2. About semantic versioning — npm documentation
  3. node-semver: the semver parser and range evaluator used by npm — npm project