Semver Checker — TypeScript source
Parse, compare, and validate Semantic Versioning 2.0.0 strings. Check which of two versions is greater (with full prerelease precedence), test whether a version satisfies an npm-style range (^, ~, comparators, hyphen, ||), and bump major/minor/patch/prerelease. Runs 100% client-side.
This is the TypeScript implementation — the same logic the interactive tool runs, in a shareable, citable form.
// Pure logic for the Semver Checker. No React, no DOM - the unit-test surface.
// Fully deterministic: every function depends only on its inputs.
//
// Implements Semantic Versioning 2.0.0 (semver.org): parsing, precedence
// comparison (including prerelease ordering), npm-style range satisfaction
// (^, ~, comparators, *, AND, ||, hyphen ranges), and version bumping.
export interface Semver {
major: number;
minor: number;
patch: number;
/** Prerelease identifiers, e.g. ['alpha', '1'] or ['0', '3', '7']. */
prerelease: string[];
/** Build-metadata identifiers (ignored for precedence). */
build: string[];
}
const IDENT = '(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)';
const PRE = `(${IDENT}(?:\\.${IDENT})*)`;
const BUILD = `([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*)`;
const NUM = '(0|[1-9]\\d*)';
// Strict semver 2.0.0 core. Leading 'v'/whitespace are stripped before matching.
const SEMVER_RE = new RegExp(`^${NUM}\\.${NUM}\\.${NUM}(?:-${PRE})?(?:\\+${BUILD})?$`);
/** Parse a strict semver string (an optional leading `v` is tolerated). */
export function parseSemver(v: string): Semver | null {
const t = v.trim().replace(/^[vV]/, '');
const m = t.match(SEMVER_RE);
if (!m) return null;
return {
major: parseInt(m[1], 10),
minor: parseInt(m[2], 10),
patch: parseInt(m[3], 10),
prerelease: m[4] ? m[4].split('.') : [],
build: m[5] ? m[5].split('.') : [],
};
}
/** Render a Semver back to its canonical string form. */
export function format(s: Semver): string {
let out = `${s.major}.${s.minor}.${s.patch}`;
if (s.prerelease.length) out += `-${s.prerelease.join('.')}`;
if (s.build.length) out += `+${s.build.join('.')}`;
return out;
}
/** Compare two prerelease identifiers (numeric < non-numeric; numeric by value). */
function cmpIdent(x: string, y: string): number {
const xn = /^[0-9]+$/.test(x);
const yn = /^[0-9]+$/.test(y);
if (xn && yn) {
const a = parseInt(x, 10);
const b = parseInt(y, 10);
return a < b ? -1 : a > b ? 1 : 0;
}
if (xn) return -1; // numeric always lower than alphanumeric
if (yn) return 1;
return x < y ? -1 : x > y ? 1 : 0;
}
/** Compare two prerelease arrays per semver precedence (a release with no
* prerelease has HIGHER precedence than one with). */
function cmpPrerelease(a: string[], b: string[]): number {
if (a.length === 0 && b.length === 0) return 0;
if (a.length === 0) return 1; // no prerelease > prerelease
if (b.length === 0) 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;
}
// All shared identifiers equal → a larger set of fields wins.
return a.length < b.length ? -1 : a.length > b.length ? 1 : 0;
}
/**
* Compare two semvers by precedence. Returns -1 if a < b, 0 if equal, 1 if a > b.
* Build metadata is ignored.
*/
export function compare(a: Semver, b: Semver): -1 | 0 | 1 {
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;
const c = cmpPrerelease(a.prerelease, b.prerelease);
return c < 0 ? -1 : c > 0 ? 1 : 0;
}
// ─── Range satisfaction ──────────────────────────────────────────────────────
/** A partial version for ranges: missing minor/patch are wildcards. */
interface RangeVer {
major: number | null;
minor: number | null;
patch: number | null;
}
type Op = '>=' | '>' | '<=' | '<' | '=';
interface Test {
op: Op;
v: Semver;
}
const sem = (major: number, minor: number, patch: number): Semver => ({
major, minor, patch, prerelease: [], build: [],
});
const ge = (v: Semver): Test => ({ op: '>=', v });
const gt = (v: Semver): Test => ({ op: '>', v });
const lt = (v: Semver): Test => ({ op: '<', v });
const le = (v: Semver): Test => ({ op: '<=', v });
const eq = (v: Semver): Test => ({ op: '=', v });
/** Parse a (possibly partial) range version: `1`, `1.2`, `1.2.3`, `1.x`, `*`. */
function parseRangeVer(s: string): RangeVer | null {
const t = s.trim().replace(/^[vV]/, '');
if (t === '' || t === '*' || t === 'x' || t === 'X') return { major: null, minor: null, patch: null };
const parts = t.split('.');
if (parts.length > 3) return null;
const part = (p: string): number | null | true => {
if (p === 'x' || p === 'X' || p === '*') return null;
if (/^\d+$/.test(p)) return parseInt(p, 10);
return true; // invalid
};
const major = part(parts[0]);
if (major === true) return null;
const minor = parts.length >= 2 ? part(parts[1]) : null;
if (minor === true) return null;
const patch = parts.length >= 3 ? part(parts[2]) : null;
if (patch === true) return null;
if (major === null) return { major: null, minor: null, patch: null };
if (minor === null) return { major, minor: null, patch: null };
return { major, minor, patch };
}
/** Build the test list for a plain comparator (`>=`, `>`, `<=`, `<`, `=`/bare). */
function rangeVerTests(op: Op | 'bare', rv: RangeVer): Test[] {
if (rv.major === null) return []; // wildcard → matches anything
const M = rv.major;
switch (op) {
case '=':
case 'bare':
if (rv.minor === null) return [ge(sem(M, 0, 0)), lt(sem(M + 1, 0, 0))];
if (rv.patch === null) return [ge(sem(M, rv.minor, 0)), lt(sem(M, rv.minor + 1, 0))];
return [eq(sem(M, rv.minor, rv.patch))];
case '>=':
if (rv.minor === null) return [ge(sem(M, 0, 0))];
if (rv.patch === null) return [ge(sem(M, rv.minor, 0))];
return [ge(sem(M, rv.minor, rv.patch))];
case '>':
if (rv.minor === null) return [ge(sem(M + 1, 0, 0))];
if (rv.patch === null) return [ge(sem(M, rv.minor + 1, 0))];
return [gt(sem(M, rv.minor, rv.patch))];
case '<=':
if (rv.minor === null) return [lt(sem(M + 1, 0, 0))];
if (rv.patch === null) return [lt(sem(M, rv.minor + 1, 0))];
return [le(sem(M, rv.minor, rv.patch))];
case '<':
if (rv.minor === null) return [lt(sem(M, 0, 0))];
if (rv.patch === null) return [lt(sem(M, rv.minor, 0))];
return [lt(sem(M, rv.minor, rv.patch))];
}
}
/** Caret (`^`) range: compatible-with, never breaking the left-most non-zero. */
function caretTests(rv: RangeVer): Test[] {
if (rv.major === null) return [];
const M = rv.major;
const lo = ge(sem(M, rv.minor ?? 0, rv.patch ?? 0));
let hi: Test;
if (rv.minor === null) {
hi = lt(sem(M + 1, 0, 0)); // ^1 → <2.0.0, ^0 → <1.0.0
} else if (rv.patch === null) {
hi = M > 0 ? lt(sem(M + 1, 0, 0)) : lt(sem(0, rv.minor + 1, 0)); // ^0.2 → <0.3.0
} else {
hi = M > 0 ? lt(sem(M + 1, 0, 0))
: rv.minor > 0 ? lt(sem(0, rv.minor + 1, 0))
: lt(sem(0, 0, rv.patch + 1)); // ^0.0.3 → <0.0.4
}
return [lo, hi];
}
/** Tilde (`~`) range: patch-level changes only (or minor-level for partials). */
function tildeTests(rv: RangeVer): Test[] {
if (rv.major === null) return [];
const M = rv.major;
const lo = ge(sem(M, rv.minor ?? 0, rv.patch ?? 0));
const hi = rv.minor === null ? lt(sem(M + 1, 0, 0)) : lt(sem(M, rv.minor + 1, 0));
return [lo, hi];
}
/** Parse a single comparator token into a list of tests (all must hold). */
function parseComparator(token: string): Test[] | null {
const t = token.trim();
if (t === '' || t === '*') return [];
if (t[0] === '^') {
const rv = parseRangeVer(t.slice(1));
return rv ? caretTests(rv) : null;
}
if (t[0] === '~') {
const rv = parseRangeVer(t.slice(1));
return rv ? tildeTests(rv) : null;
}
let op: Op | 'bare' = 'bare';
let rest = t;
if (t.startsWith('>=')) { op = '>='; rest = t.slice(2); }
else if (t.startsWith('<=')) { op = '<='; rest = t.slice(2); }
else if (t.startsWith('>')) { op = '>'; rest = t.slice(1); }
else if (t.startsWith('<')) { op = '<'; rest = t.slice(1); }
else if (t.startsWith('=')) { op = '='; rest = t.slice(1); }
const rv = parseRangeVer(rest);
return rv ? rangeVerTests(op, rv) : null;
}
function check(test: Test, v: Semver): boolean {
const c = compare(v, test.v);
switch (test.op) {
case '>': return c > 0;
case '>=': return c >= 0;
case '<': return c < 0;
case '<=': return c <= 0;
case '=': return c === 0;
}
}
/** Evaluate one AND-clause (already split from `||`). */
function clauseMatches(v: Semver, clause: string): boolean {
const c = clause.trim();
if (c === '' || c === '*') return true;
// Hyphen range: "1.2.3 - 2.3.4" → >=lower <=upper (partials apply).
if (/\s+-\s+/.test(c)) {
const parts = c.split(/\s+-\s+/);
if (parts.length === 2) {
const lo = parseRangeVer(parts[0]);
const hi = parseRangeVer(parts[1]);
if (!lo || !hi) return false;
const tests = [...rangeVerTests('>=', lo), ...rangeVerTests('<=', hi)];
return tests.every((t) => check(t, v));
}
}
const tokens = c.split(/\s+/).filter((t) => t.length > 0);
const tests: Test[] = [];
for (const tok of tokens) {
const ts = parseComparator(tok);
if (ts === null) return false; // invalid comparator → clause unsatisfiable
tests.push(...ts);
}
return tests.length === 0 ? true : tests.every((t) => check(t, v));
}
/**
* Does `version` satisfy the npm-style `range`? Supports `^`, `~`,
* comparators (`>=`,`<=`,`>`,`<`,`=`), `*`, partials (`1.2`, `1`), hyphen
* ranges (`1.2.3 - 2.3.4`), space-separated AND, and `||` OR. An invalid
* version or wholly-unparseable range yields `false`; `*`/empty matches all.
*/
export function satisfies(version: string, range: string): boolean {
const v = parseSemver(version);
if (!v) return false;
return range.split('||').some((clause) => clauseMatches(v, clause));
}
/**
* Bump a version by `kind`. major/minor/patch drop any prerelease and produce a
* clean release; prerelease bumps the trailing numeric prerelease identifier
* (or appends `-0` / `.1` when there is none / a non-numeric tail). Invalid
* input is returned unchanged.
*/
export function bump(v: string, kind: 'major' | 'minor' | 'patch' | 'prerelease'): string {
const s = parseSemver(v);
if (!s) return v;
const { major, minor, patch, prerelease } = s;
switch (kind) {
case 'major': return `${major + 1}.0.0`;
case 'minor': return `${major}.${minor + 1}.0`;
case 'patch': return `${major}.${minor}.${patch + 1}`;
case 'prerelease': {
if (prerelease.length === 0) return `${major}.${minor}.${patch + 1}-0`;
const last = prerelease[prerelease.length - 1];
if (/^[0-9]+$/.test(last)) {
const next = [...prerelease];
next[next.length - 1] = String(parseInt(last, 10) + 1);
return `${major}.${minor}.${patch}-${next.join('.')}`;
}
return `${major}.${minor}.${patch}-${[...prerelease, '1'].join('.')}`;
}
}
}
Also available in 13 other languages
Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →