Implementing semver 2.0.0 from scratch in TypeScript — no semver package allowed

typescript dev.to

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>
Enter fullscreen mode Exit fullscreen mode

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-]+)*))?$/;
Enter fullscreen mode Exit fullscreen mode

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(),
  };
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. 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 -alpha makes a version less than the release, not greater.

  2. Pre-release identifiers are compared left to right, one at a time.

  3. Numeric identifiers are compared as integers. beta.2 < beta.11 because 2 < 11, not because "2" < "11" lexicographically (which would give the opposite result since "2" > "1").

  4. A numeric identifier always has lower precedence than a string identifier. 1 < alpha regardless of the actual values.

  5. 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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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));
  };
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 v prefix
  • 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.x behaviour), 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);
}
Enter fullscreen mode Exit fullscreen mode

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 using node: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"]
Enter fullscreen mode Exit fullscreen mode

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

Source: dev.to

arrow_back Back to Tutorials