Implementing semver 2.0.0 from scratch in TypeScript — no semver package allowed
semver-cli— a TypeScript CLI that parses, compares, bumps, sorts, and range-checks semantic versions. Implements the full semver 2.0.0 spec from scratch with zero runtime dependencies and 55 tests.
The semver npm package has over 300 million weekly downloads. It's the backbone of every package.json resolution in the Node.js ecosystem. You use it every time you run npm install, whether you know it or not.
But have you ever read the actual semver 2.0.0 specification? The full thing, including section 11 on pre-release precedence? Most developers know the basics — MAJOR.MINOR.PATCH, break stuff on major, add features on minor, fix bugs on patch. But the spec has subtleties that most people never encounter until something breaks in their CI pipeline at 3 AM.
I built semver-cli to understand the specification at the implementation level. No semver package, no shortcuts. Just the BNF grammar from semver.org, a regex, and comparator functions. The result is a CLI toolkit that does five things: parse, compare, bump, range-check, and sort semantic versions.
What semver 2.0.0 actually specifies
The specification lives at semver.org and it's surprisingly readable. The core grammar is a BNF definition that breaks a version string into three required components and two optional ones:
<valid semver> ::= <version core>
| <version core> "-" <pre-release>
| <version core> "+" <build>
| <version core> "-" <pre-release> "+" <build>
<version core> ::= <major> "." <minor> "." <patch>
Major, minor, and patch are non-negative integers without leading zeros. That last part matters — 01.2.3 is invalid. 1.02.3 is invalid. The regex must reject them.
Pre-release identifiers are dot-separated and can be either numeric (digits only, no leading zeros) or alphanumeric (containing at least one non-digit). Build metadata follows the same dot-separated format but is ignored in precedence calculations.
The regex
Translating the BNF into a regular expression gives you this:
const SEMVER_RE =
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;
This looks intimidating but it's a direct encoding. (0|[1-9]\d*) matches a non-negative integer without leading zeros: either exactly 0, or a non-zero digit followed by any digits. The pre-release section uses (?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*) to match either a numeric identifier (no leading zeros) or an alphanumeric identifier (contains at least one letter or hyphen).
The parse function extracts capture groups and converts numeric pre-release identifiers to actual numbers:
export function parse(version: string): SemVer {
const match = SEMVER_RE.exec(version.trim());
if (!match) throw new Error(`Invalid semver: "${version}"`);
const [, majorStr, minorStr, patchStr, preStr, buildStr] = match;
const prerelease = preStr
? preStr.split(".").map((id) => {
const n = Number(id);
return /^\d+$/.test(id) ? n : id;
})
: [];
const build = buildStr ? buildStr.split(".") : [];
return {
major: Number(majorStr),
minor: Number(minorStr),
patch: Number(patchStr),
prerelease,
build,
raw: version.trim(),
};
}
This distinction between numeric and string pre-release identifiers is critical for comparison, as we'll see next.
Section 11: pre-release precedence
Section 11 of the spec is where most implementations either get it right or subtly wrong. The rules for comparing pre-release versions are:
A version with a pre-release tag has lower precedence than the same version without one. So
1.0.0-alpha < 1.0.0. This is counterintuitive to some people — adding-alphamakes a version less than the release, not greater.Pre-release identifiers are compared left to right, one at a time.
Numeric identifiers are compared as integers.
beta.2 < beta.11because 2 < 11, not because"2" < "11"lexicographically (which would give the opposite result since"2" > "1").A numeric identifier always has lower precedence than a string identifier.
1 < alpharegardless of the actual values.A shorter set of identifiers has lower precedence if all preceding identifiers are equal.
alpha < alpha.1.
The spec provides a concrete ordering example:
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
My implementation handles this with a dedicated comparePre function:
function comparePre(
a: readonly (string | number)[],
b: readonly (string | number)[]
): -1 | 0 | 1 {
if (a.length === 0 && b.length === 0) return 0;
if (a.length === 0) return 1; // release > pre-release
if (b.length === 0) return -1; // pre-release < release
const len = Math.max(a.length, b.length);
for (let i = 0; i < len; i++) {
if (i >= a.length) return -1; // shorter = lower precedence
if (i >= b.length) return 1;
const ai = a[i], bi = b[i];
if (ai === bi) continue;
const aNum = typeof ai === "number";
const bNum = typeof bi === "number";
if (aNum && !bNum) return -1; // numeric < string
if (!aNum && bNum) return 1;
if (aNum && bNum) return (ai as number) < (bi as number) ? -1 : 1;
return (ai as string) < (bi as string) ? -1 : 1;
}
return 0;
}
The test suite validates the exact ordering from the spec. Each pair in the sequence is checked both directions — compare(a, b) === -1 and compare(b, a) === 1 — to ensure the comparison is antisymmetric.
Build metadata: present but ignored
Section 10 of the spec says build metadata "SHOULD be ignored when determining version precedence." My implementation enforces this:
expect(compare("1.0.0+build1", "1.0.0+build2")).toBe(0);
Build metadata is parsed and stored in the SemVer object, so parse("1.0.0+build42") gives you build: ["build42"]. But the compare function never looks at it. This matches what npm does — two packages that differ only in build metadata are considered the same version.
Bump: version arithmetic
The bump function implements the common version incrementing patterns. The basic cases are straightforward:
bump("1.2.3", "major") → "2.0.0"
bump("1.2.3", "minor") → "1.3.0"
bump("1.2.3", "patch") → "1.2.4"
The interesting edge case is bumping patch on a pre-release version. If the version is 1.2.3-alpha.1, bumping the patch should give 1.2.3, not 1.2.4. The pre-release was a preview of 1.2.3, so "releasing" it means dropping the pre-release tag and keeping the same version core.
Pre-release bumps are more complex. bump("1.2.3", "premajor", "alpha") gives 2.0.0-alpha.0. The .0 suffix is a counter that gets incremented on subsequent prerelease bumps:
bump("2.0.0-alpha.0", "prerelease") → "2.0.0-alpha.1"
bump("2.0.0-alpha.1", "prerelease") → "2.0.0-alpha.2"
If the last pre-release identifier is a string (not a number), a .0 is appended instead:
bump("1.2.3-alpha", "prerelease") → "1.2.3-alpha.0"
Range matching: the npm convention
The semver spec itself doesn't define range syntax. Range expressions like >=1.2.0 <2.0.0, ^1.0.0, and ~1.2.3 are conventions established by npm and adopted widely. My implementation supports the full set:
Comparators: >, >=, <, <=, = — straightforward version comparison against a target.
Tilde ranges: ~1.2.3 means >=1.2.3 <1.3.0-0. It allows patch-level changes. The -0 upper bound ensures that pre-releases of the next minor (like 1.3.0-alpha) are excluded.
Caret ranges: ^1.2.3 means >=1.2.3 <2.0.0-0. It allows changes that don't modify the leftmost non-zero digit. This is what npm uses by default in package.json. The behaviour changes at 0.x:
^0.2.3 → >=0.2.3 <0.3.0-0 (minor is leftmost non-zero)
^0.0.3 → >=0.0.3 <0.0.4-0 (patch is leftmost non-zero)
X-ranges: 1.x, 1.2.*, * — wildcard matching on any component. 1.x is equivalent to >=1.0.0 <2.0.0-0.
Hyphen ranges: 1.0.0 - 2.0.0 means >=1.0.0 <=2.0.0. If the upper bound is partial (like 1.0.0 - 2.3), it becomes >=1.0.0 <2.4.0-0.
Logical operators: Space means AND, || means OR. So >=1.0.0 <2.0.0 || >=3.0.0 matches versions in either range.
The implementation parses each range expression into a predicate function:
export function parseRange(range: string): (version: string) => boolean {
const orSets = range.split("||").map((s) => s.trim());
const predicates = orSets.map(parseAndSet);
return (version: string) => {
const v = parse(version);
return predicates.some((pred) => pred(v));
};
}
Each AND-set is split into tokens, each token is parsed into one or more comparator functions, and the final predicate requires all comparators in an AND-set to pass while only needing one OR-set to succeed.
The CLI interface
The CLI uses node:util.parseArgs for argument parsing — no commander, no yargs, no external dependencies:
# Structured JSON output
$ semver-cli parse 1.2.3-beta.1+build42
{
"major": 1,
"minor": 2,
"patch": 3,
"prerelease": ["beta", 1],
"build": ["build42"],
"raw": "1.2.3-beta.1+build42"
}
# Version comparison with human-readable output
$ semver-cli compare 1.0.0-alpha 1.0.0
-1 (1.0.0-alpha < 1.0.0)
# Version bumping
$ semver-cli bump 1.2.3 --minor
1.3.0
# Range checking with exit codes for scripting
$ semver-cli range ">=1.2.0 <2.0.0" 1.5.3
1.5.3 matches ">=1.2.0 <2.0.0" # exit 0
$ semver-cli range "^1.0.0" 2.0.0
2.0.0 does not match "^1.0.0" # exit 1
# Sorting
$ semver-cli sort 2.0.0 1.0.0-alpha 1.5.0 1.0.0
1.0.0-alpha
1.0.0
1.5.0
2.0.0
The range command uses exit codes for shell scripting: 0 if the version matches, 1 if it doesn't. This lets you use it in if statements and CI pipelines without parsing output.
Testing the specification
The test suite has 55 tests covering:
-
Parsing: valid versions, pre-release identifiers, build metadata, numeric identifier coercion, rejection of leading zeros, rejection of incomplete versions, rejection of
vprefix - Comparison: major/minor/patch ordering, pre-release precedence per section 11 (including the full example ordering), numeric vs string identifier comparison, build metadata ignored
- Bump: all seven bump types, pre-release edge cases, pre-release identifier passthrough
-
Range: exact match, all six comparator operators, AND/OR combinations, tilde ranges, caret ranges (including
0.xbehaviour), x-ranges, hyphen ranges, wildcard, pre-release versions in ranges - Sort: ascending order, pre-release ordering, stability with build metadata
The section 11 precedence test deserves special mention. It walks through the exact ordering example from the spec:
const versions = [
"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 = 0; i < versions.length - 1; i++) {
expect(compare(versions[i], versions[i + 1])).toBe(-1);
expect(compare(versions[i + 1], versions[i])).toBe(1);
}
This single test validates all five sub-rules of section 11 at once: pre-release vs release, numeric vs string, shorter vs longer, numeric ordering, and string ordering.
Architecture
The codebase is three files:
-
src/semver.ts— Pure functions for parse, compare, sort, bump, format, tryParse. No side effects, no I/O. -
src/range.ts— Range parsing and matching. Imports parse and compare from semver.ts. -
src/cli.ts— CLI entry point usingnode:util.parseArgs. Imports from both modules.
This separation means the core logic is reusable as a library. The SemVer interface and all functions are exported. The CLI is just a thin wrapper that handles argument parsing, error formatting, and exit codes.
The project uses TypeScript with strict mode, ES2022 target, and ESNext modules. tsx is a dev dependency for running TypeScript directly during development. vitest handles testing. The only dependencies are dev dependencies — the compiled output is pure JavaScript with zero runtime dependencies.
Docker
The Dockerfile uses a multi-stage build. The first stage installs dependencies and compiles TypeScript. The second stage copies only the compiled JavaScript and runs as a non-root user:
FROMnode:20-alpineASbuild
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --ignore-scripts
COPY tsconfig.json ./
COPY src/ src/
RUN npx tsc
FROM node:20-alpine
RUN addgroup -S app && adduser -S app -G app
WORKDIR /app
COPY --from=build /app/dist/ dist/
COPY --from=build /app/package.json ./
USER app
ENTRYPOINT ["node", "dist/cli.js"]
The final image contains no TypeScript compiler, no test framework, no source maps — just the compiled JavaScript and Node.js.
What I learned
Implementing semver from scratch taught me three things:
First, the spec is well-designed. The BNF grammar is unambiguous, the precedence rules are complete, and the examples cover the tricky cases. Most "semver bugs" in the wild aren't spec bugs — they're implementation bugs or misunderstandings of what the spec actually says.
Second, range syntax is not part of the spec. Everything after the basic MAJOR.MINOR.PATCH[-prerelease][+build] format is convention. Tilde, caret, x-ranges, hyphen ranges — these are npm's invention, not semver's. The spec doesn't even mention ranges. This means different tools can legitimately disagree on range semantics.
Third, the 0.x caret behaviour is the most commonly misunderstood rule. ^0.2.3 doesn't mean >=0.2.3 <1.0.0 — it means >=0.2.3 <0.3.0. At 0.x, every minor bump is a potential breaking change. This catches people off guard when they depend on ^0.x packages and wonder why npm won't install the next minor version.
The full source is on GitHub. Clone it, run npm test, and try npx tsx src/cli.ts parse on your own version strings.
Source: github.com/sen-ltd/semver-cli