Skip to content

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 →