Semantic Version Comparison

Parse semantic versions and compare major, minor, patch, and prerelease identifiers according to SemVer precedence rules.

TypescriptCli Tooling

Code

snippet
export interface SemVersion {
  readonly major: number;
  readonly minor: number;
  readonly patch: number;
  readonly prerelease: ReadonlyArray<string | number>;
}

const SEMVER_PATTERN =
  /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:[0-9A-Za-z-]+)(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;

function parseIdentifier(identifier: string): string | number {
  if (!/^\d+$/.test(identifier)) return identifier;
  if (identifier.length > 1 && identifier.startsWith("0")) {
    throw new Error(`invalid numeric identifier "${identifier}"`);
  }
  const value = Number(identifier);
  if (!Number.isSafeInteger(value)) {
    throw new Error(`identifier "${identifier}" is too large`);
  }
  return value;
}

export function parseSemVer(version: string): SemVersion {
  if (typeof version !== "string") {
    throw new TypeError("version must be a string");
  }
  const match = SEMVER_PATTERN.exec(version.trim());
  if (match === null) {
    throw new Error(`invalid semantic version: "${version}"`);
  }
  const major = Number(match[1]);
  const minor = Number(match[2]);
  const patch = Number(match[3]);
  if (![major, minor, patch].every((value) => Number.isSafeInteger(value))) {
    throw new Error("version numbers must be safe integers");
  }
  const prerelease =
    match[4] === undefined
      ? []
      : match[4].split(".").map(parseIdentifier);
  return { major, minor, patch, prerelease };
}

function compareIdentifiers(
  left: string | number,
  right: string | number,
): -1 | 0 | 1 {
  if (typeof left === "number" && typeof right === "number") {
    if (left === right) return 0;
    return left < right ? -1 : 1;
  }
  if (typeof left === "number") return -1;
  if (typeof right === "number") return 1;
  if (left === right) return 0;
  return left < right ? -1 : 1;
}

export function compareSemVer(
  left: SemVersion,
  right: SemVersion,
): -1 | 0 | 1 {
  if (left.major !== right.major) return left.major < right.major ? -1 : 1;
  if (left.minor !== right.minor) return left.minor < right.minor ? -1 : 1;
  if (left.patch !== right.patch) return left.patch < right.patch ? -1 : 1;

  if (left.prerelease.length === 0 && right.prerelease.length === 0) {
    return 0;
  }
  if (left.prerelease.length === 0) return 1;
  if (right.prerelease.length === 0) return -1;

  const length = Math.max(
    left.prerelease.length,
    right.prerelease.length,
  );
  for (let index = 0; index < length; index += 1) {
    if (index >= left.prerelease.length) return -1;
    if (index >= right.prerelease.length) return 1;
    const result = compareIdentifiers(
      left.prerelease[index],
      right.prerelease[index],
    );
    if (result !== 0) return result;
  }
  return 0;
}

export function compareVersions(left: string, right: string): -1 | 0 | 1 {
  return compareSemVer(parseSemVer(left), parseSemVer(right));
}

export function sortVersions(versions: readonly string[]): string[] {
  return versions
    .map((version) => ({ version, parsed: parseSemVer(version) }))
    .sort((left, right) => compareSemVer(left.parsed, right.parsed))
    .map((entry) => entry.version);
}

const versions = [
  "1.0.0",
  "1.0.0-alpha",
  "1.0.0-alpha.1",
  "1.0.0-beta.2",
  "1.0.0-rc.1",
  "1.0.1",
  "2.0.0",
];
console.log(sortVersions(versions));
console.log(compareVersions("1.0.0+build.5", "1.0.0+build.9"));
console.log(compareVersions("1.0.0-alpha.10", "1.0.0-alpha.2"));

try {
  parseSemVer("01.0.0");
} catch (error) {
  console.log(error instanceof Error ? error.message : String(error));
}