Semver Range Checker: Does This Version Actually Satisfy That Range?

A dependency pinned to ^0.4.2 in a package.json gets a minor version bump to 0.5.0, and suddenly a build starts failing in CI with no code changes on your end at all. The instinctive reaction is confusion, since caret ranges are supposed to allow non-breaking updates. What actually happened is that caret behaves differently for versions below 1.0.0 than the way most developers assume, and that specific difference is one of the most common, least understood parts of semver in daily use.

This tool checks whether a specific version actually satisfies a given range, using an implementation validated directly against the canonical semver npm package across dozens of test cases, including the exact edge cases, the 0.x.x caret behavior and prerelease exclusion rules, that most explanations either get wrong or skip over entirely.

Check a Version Against a Range

What Semantic Versioning Actually Encodes

A semver version number, major.minor.patch, is meant to communicate the nature of a change, not just track history. A patch bump (1.2.3 to 1.2.4) signals a backward-compatible bug fix, nothing that should break code depending on the package. A minor bump (1.2.3 to 1.3.0) signals new, backward-compatible functionality added, safe to adopt without changing your own code. A major bump (1.2.3 to 2.0.0) signals a breaking change, something that could require updates to code depending on the package.

Ranges exist specifically to let a package.json express “any version compatible with what I tested against” rather than pinning to one exact version forever, which would mean manually bumping every dependency by hand to get even routine bug fixes. The trade-off is that a range’s usefulness depends entirely on package authors actually following semver correctly, and a package that ships a breaking change in what it calls a minor or patch release will break every consumer trusting the range to mean what it’s supposed to mean.

Caret Ranges (^), and the 0.x.x Trap

A caret range like ^1.2.3 allows any version considered compatible according to semver’s definition of compatibility, which for a version with a major number greater than zero means anything from 1.2.3 up to, but not including, 2.0.0. This is the most commonly used range type in modern package.json files, and it matches the general expectation that a package won’t intentionally break consumers within the same major version.

Caret means something meaningfully narrower below version 1.0.0

For a version with major number 0, semver treats the entire 0.x line as inherently unstable, meaning any change could be breaking, and caret ranges reflect that by narrowing what counts as “compatible.” ^0.2.3 only allows patch-level changes, anything from 0.2.3 up to but not including 0.3.0, not the full 0.x range some developers assume. And ^0.0.3 narrows further still, matching only that exact version, since even a patch bump on a 0.0.x version isn’t considered safe by the specification. This is exactly the behavior behind the scenario described at the start of this piece: a dependency still below 1.0.0 got a minor bump that a caret range correctly, if surprisingly, didn’t allow.

Tilde Ranges (~)

A tilde range like ~1.2.3 allows patch-level changes only, anything from 1.2.3 up to but not including 1.3.0, when a minor version is specified in the range. This is meaningfully narrower than a caret range for the same starting version, and it’s the right choice specifically when you want to allow bug fixes but not new features, which some teams prefer as a more conservative default than caret for dependencies where new functionality carries real risk of unexpected behavior changes even when technically non-breaking.

When a tilde range omits the patch version entirely, like ~1.2, it behaves the same as specifying the patch as zero, allowing the same 1.2.x range. When it omits the minor version too, like ~1, it broadens to allow the entire 1.x range instead, matching caret’s behavior for that specific case.

Comparison Operators and Combining Them

Standard comparison operators, >, >=, <, <=, and =, work as expected on their own, but they become genuinely useful combined with a space, which acts as a logical AND. >=1.2.3 <2.0.0 matches exactly what a caret range on 1.2.3 would express, and writing it out explicitly like this is common when a range needs a lower bound and upper bound that don’t correspond neatly to a single caret or tilde expression.

This explicit form is also useful for expressing ranges that skip a specific known-broken version, or that need an upper bound tighter than what caret or tilde would naturally produce, giving more granular control at the cost of being more verbose than the shorthand operators.

X-Ranges

An x-range, using x, X, or * in place of a version component, means “any value is acceptable at this position.” 1.2.x matches any patch version within 1.2, equivalent to ~1.2.0. 1.x matches any minor and patch version within major version 1, equivalent to ^1.0.0. A bare *, or an empty range string, matches any version at all, which is rarely a good idea in a real dependency declaration, since it accepts even a major, potentially breaking version bump without any warning at all.

Hyphen Ranges

A hyphen range, written as 1.2.3 - 2.3.4, matches any version inclusively between the two bounds, a simpler and more explicit alternative to writing the equivalent comparison operators by hand. This is useful when a compatible range doesn’t correspond neatly to a caret or tilde expression, such as supporting a specific window across a version bump that changed something non-standard partway through.

OR Logic With ||

Multiple ranges separated by || mean a version needs to satisfy at least one of them, not all. 1.0.0 || >=1.2.0 <2.0.0 matches either exactly version 1.0.0, or anything in the 1.2.x through 1.x range, a pattern useful when a package needs to explicitly support two disjoint version windows, commonly after a version that was reverted or skipped for some reason in the package’s own history.

Prerelease Versions and Why Ranges Usually Exclude Them

A version like 1.0.0-alpha or 2.0.0-beta.3 includes a prerelease identifier, and semver’s specification treats prerelease versions as deliberately excluded from ranges by default, even ranges that would otherwise seem to cover them numerically. 1.0.0-alpha does not satisfy ^1.0.0, despite 1.0.0-alpha being numerically “less than” 1.0.0 in a way that might seem like it should fall within a caret range starting at that version.

This exclusion is deliberate and exists for a good reason: prerelease versions are explicitly unstable and not meant to be pulled in automatically by a normal dependency range, which could otherwise silently install an alpha or beta build in place of a stable release simply because a range’s numeric bounds happened to include it. To actually match a prerelease version, a range typically needs to include a comparator with a matching prerelease identifier on the same major.minor.patch, which is why prerelease versions of a dependency usually need to be pinned to an exact version rather than expressed through a normal range at all.

How This Connects to package-lock.json

The ranges in package.json describe what’s acceptable, while package-lock.json (or the equivalent lockfile for your package manager) pins the exact, specific version actually installed at the time the lockfile was generated. This is why two developers running npm install against the same package.json but different, or missing, lockfiles can end up with genuinely different dependency versions installed, both technically satisfying the same ranges, but resolving to different exact versions within them. Understanding what a given range actually allows, which is exactly what this tool checks directly, is useful context for understanding why a lockfile update sometimes pulls in a version that technically satisfies every range in package.json while still introducing a behavior change nobody was expecting.

Common Mistakes

Assuming caret behaves the same regardless of the major version is the single most common mistake covered in depth above, and it’s worth restating plainly: caret on a 0.x.x version is considerably narrower than the “same major version” behavior it provides above 1.0.0, and a lot of unexpected breakage from routine dependency updates traces directly back to this specific misunderstanding.

Using a bare * or an overly permissive range in a published package’s own dependencies, rather than a deliberately chosen caret or tilde range, accepts essentially any future version of a dependency without any real protection against a breaking change, defeating much of the purpose of using semver ranges at all.

Assuming a range automatically excludes a version the package author intended to be broken or yanked is another common misunderstanding. Semver ranges are purely mechanical, evaluating version numbers against the range’s rules with no awareness of a package’s actual release notes or known issues, so a specific problematic version still satisfies a range that numerically includes it unless the range is deliberately adjusted to exclude it.

And forgetting that prerelease versions need explicit handling, expecting a normal range to naturally exclude or include a prerelease based on intuition rather than the specification’s actual, deliberate exclusion rule, leads to confusion in both directions, either a prerelease unexpectedly failing to satisfy a range that seems like it numerically should cover it, or a range accidentally written in a way that does pull in an unstable prerelease build.

Choosing the Right Range When Publishing Your Own Package

For a package’s own dependencies, caret is the reasonable default for most cases, trusting semver’s compatibility promise while still allowing routine updates to flow through automatically. Tilde is worth choosing deliberately when a specific dependency has a history of introducing subtle behavior changes even in minor releases, since narrowing to patch-only updates trades some convenience for more predictability.

Pinning to an exact version, with no range at all, is appropriate for dependencies where any change, even a patch, needs manual review before adoption, commonly security-sensitive packages or ones with a track record of unreliable release quality. This trades automatic updates entirely for maximum control, and it means security patches also need to be applied manually rather than flowing in automatically, which is a real trade-off worth being deliberate about rather than defaulting to out of general caution alone.

FAQ

What’s the difference between ^1.2.3 and ~1.2.3?

Caret allows any version considered compatible under semver’s rules, up to the next major version for a version 1.0.0 or above. Tilde is narrower, only allowing patch-level changes within the same minor version.

Why doesn’t ^0.2.3 allow 0.3.0?

Semver treats any version below 1.0.0 as inherently unstable, where even a minor bump could be breaking. Caret reflects this by only allowing patch-level changes for 0.x versions, rather than the full “same major version” range it allows above 1.0.0.

Does a normal range like ^1.0.0 match a prerelease version like 1.0.0-alpha?

No. Semver deliberately excludes prerelease versions from ranges by default, even when they would seem to numerically fall within the range’s bounds, specifically to prevent an unstable build from being installed automatically.

Should I pin exact versions instead of using ranges?

For most dependencies, a caret or tilde range gives a reasonable balance of receiving updates automatically while limiting exposure to breaking changes. Exact pinning makes sense specifically for dependencies where every change, including patches, needs manual review before being adopted.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top