---
title: 'Semantic Versioning'
source: 'https://academia.sh/en/courses/javascript-ecosystem/semantic-versioning'
course: 'Modules, Tooling and the Ecosystem'
language: en
updated: '2026-08-17T18:09:45+00:00'
license: 'CC BY-SA 4.0'
---

# Semantic Versioning

The promise carried by the three parts of a version number, comparison and precedence rules, pre-release tags, the bounds of version ranges, and the definition of a breaking change.

The previous lesson said that a range like `^1.4.0` in the manifest denotes not a
single version but a set. What draws the set's boundaries is a contract that assigns
meaning to the parts of a version number.

This contract is called **semantic versioning**. It is not a technical mechanism, it
is a way of making a promise: the party raising the number tells the consumer what it
did. The part that is a mechanism — comparison and range arithmetic — is built on top
of the contract.

## The Promise the Three Parts Make

A version number consists of three numbers separated by dots: **major**, **minor**,
and **patch**. Which part gets raised when a release is prepared depends on the kind of
change made:

| Change | Part raised | Promise given to the consumer |
|---|---|---|
| Bug fix, internal improvement | Patch | Your code runs unchanged |
| Backward-compatible capability addition | Minor | Your code runs unchanged, and there is a new capability |
| An edit that changes existing behavior | Major | Your code may change; read the migration notes |

The subject of the promise is the package's **public interface**: the paths listed in
the exports map and the names reachable from those paths. Renaming an inner file not in
the map does not raise the major version — this is the counterpart of encapsulation.
This is the package-level contract that the backward compatibility concept introduced
in the Programming Fundamentals course turns into.

The contract itself enforces nothing. Raising the number correctly is the author's
responsibility; on the consumer side, the limit of the trust placed in the number is
what the next section's explanation of why ranges are vulnerable is about.

## Comparison and Precedence

The precondition for range arithmetic is that two versions can be compared. Comparison
cannot be done by text order: version `1.10.0` is greater than `1.9.0` but looks
smaller as text. The same problem was described for system packages in the package
manager lesson of the Introduction to Linux course; here the rule is narrower and
entirely written down.

The rule is three steps: the three numbers are compared numerically left to right; on
equality, a version carrying a pre-release tag is smaller than one without; if both
sides are pre-release, the tag parts are compared one by one.

```javascript
// file: version.mjs
// Splits a version string into the core triple and pre-release parts; build metadata is discarded.
export function parse(string) {
  const [unversioned] = string.split('+');
  const [core, preRelease = ''] = unversioned.split('-', 2);
  const [major, minor, patch] = core.split('.').map(Number);
  return {
    major,
    minor,
    patch,
    preRelease: preRelease === '' ? [] : preRelease.split('.'),
  };
}

function comparePart(a, b) {
  const aNumber = /^\d+$/.test(a);
  const bNumber = /^\d+$/.test(b);
  if (aNumber && bNumber) return Number(a) - Number(b);
  if (aNumber) return -1;          // numeric part is smaller than a lettered part
  if (bNumber) return 1;
  return a < b ? -1 : a > b ? 1 : 0;
}

export function compare(x, y) {
  const a = parse(x);
  const b = parse(y);
  for (const field of ['major', 'minor', 'patch']) {
    if (a[field] !== b[field]) return a[field] - b[field];
  }
  if (a.preRelease.length === 0 && b.preRelease.length > 0) return 1;
  if (a.preRelease.length > 0 && b.preRelease.length === 0) return -1;
  const length = Math.min(a.preRelease.length, b.preRelease.length);
  for (let i = 0; i < length; i += 1) {
    const result = comparePart(a.preRelease[i], b.preRelease[i]);
    if (result !== 0) return result;
  }
  return a.preRelease.length - b.preRelease.length;
}
```

```javascript
// file: sort.mjs -- version.mjs is the file above
import { compare } from './version.mjs';

const versions = ['1.10.0', '1.9.0', '2.0.0-rc.2', '2.0.0-rc.10', '2.0.0', '2.0.0-alpha', '1.9.0+build.7'];
console.log(versions.sort(compare).join('  <  '));
```

```
$ node sort.mjs
1.9.0  <  1.9.0+build.7  <  1.10.0  <  2.0.0-alpha  <  2.0.0-rc.2  <  2.0.0-rc.10  <  2.0.0
```

Four observations in the output summarize the whole rule.

Version `1.10.0` came after `1.9.0` — the parts were compared as numbers.

Version `2.0.0-alpha` came **before** `2.0.0`. A version carrying a pre-release tag is
smaller than an untagged version with the same core triple. The tag means "this
version is not that version yet."

Version `2.0.0-rc.2` came before `2.0.0-rc.10`. The tag's numeric parts are compared as
numbers; if text order were used, the order would come out reversed.

`1.9.0` and `1.9.0+build.7` came out **equal** in the comparison: everything after the
plus sign is **build metadata** and does not enter precedence. Because the sort is
stable, the two kept their original order in the list. Equality means the two
archives are counted as the same version; this is why build metadata cannot be used to
tell published versions apart.

## Version Ranges

The range in the manifest specifies the accepted set of versions. The two most
commonly used markers rely on two different levels of the contract: one asks the major
version to stay fixed, the other asks the minor version to stay fixed.

```javascript
// file: range.mjs -- version.mjs is the file from the previous section
import { compare, parse } from './version.mjs';

// Converts a '^' or '~' range into a [lower, upper) pair.
export function bounds(range) {
  const marker = range[0] === '^' || range[0] === '~' ? range[0] : '=';
  const base = marker === '=' ? range : range.slice(1);
  const { major, minor, patch } = parse(base);
  if (marker === '=') return [base, `${major}.${minor}.${patch + 1}`];
  if (marker === '~') return [base, `${major}.${minor + 1}.0`];
  if (major > 0) return [base, `${major + 1}.0.0`];
  if (minor > 0) return [base, `0.${minor + 1}.0`];
  return [base, `0.0.${patch + 1}`];
}

export function satisfies(version, range) {
  const [lower, upper] = bounds(range);
  const isPreRelease = parse(version).preRelease.length > 0;
  const basePreRelease = parse(lower).preRelease.length > 0;
  if (isPreRelease && !basePreRelease) return false;
  return compare(version, lower) >= 0 && compare(version, upper) < 0;
}
```

```javascript
// file: trial.mjs -- range.mjs is the file above
import { bounds, satisfies } from './range.mjs';

for (const range of ['^1.4.0', '~1.4.0', '^0.4.2', '^0.0.3', '1.4.0']) {
  const [lower, upper] = bounds(range);
  console.log(`${range.padEnd(8)} => ${lower} <= v < ${upper}`);
}

console.log('');
const trials = [
  ['1.4.9', '^1.4.0'], ['1.9.0', '^1.4.0'], ['2.0.0', '^1.4.0'],
  ['1.5.0', '~1.4.0'], ['0.4.9', '^0.4.2'], ['0.5.0', '^0.4.2'],
  ['2.0.0-rc.1', '^1.4.0'],
];
for (const [version, range] of trials) {
  console.log(`${version.padEnd(11)} ${range.padEnd(8)} ${satisfies(version, range) ? 'satisfies' : 'fails'}`);
}
```

```
$ node trial.mjs
^1.4.0   => 1.4.0 <= v < 2.0.0
~1.4.0   => 1.4.0 <= v < 1.5.0
^0.4.2   => 0.4.2 <= v < 0.5.0
^0.0.3   => 0.0.3 <= v < 0.0.4
1.4.0    => 1.4.0 <= v < 1.4.1

1.4.9       ^1.4.0   satisfies
1.9.0       ^1.4.0   satisfies
2.0.0       ^1.4.0   fails
1.5.0       ~1.4.0   fails
0.4.9       ^0.4.2   satisfies
0.5.0       ^0.4.2   fails
2.0.0-rc.1  ^1.4.0   fails
```

The caret says "let the major version not change" and accepts minor upgrades. The
tilde says "let the minor version not change either" and accepts only patch upgrades.
Unmarked notation pins a single version.

The third and fourth lines show a special rule. Packages with a major version of zero
are not considered stable; the contract is not yet in effect. This is why the caret
shifts one digit to the right at major version zero and keeps the minor version fixed.
At minor version zero it shifts one more digit. This shift keeps unstable packages from
slipping into a project through silent upgrades.

The last line shows the rule about pre-releases: version `2.0.0-rc.1`, even though it
is numerically smaller than `2.0.0`, does not fall inside range `^1.4.0`. Pre-releases
are not included in any range unless the range's base is itself already a pre-release.
Otherwise a stable range would pull in versions that count as unpublished.

## The Limits of the Contract

The convenience the range markers give — picking up minor upgrades automatically —
rests on an assumption: that the publisher keeps the minor-version promise. This
assumption weakens in three places.

**The definition of a breaking change is ambiguous.** A consumer relying on
undocumented behavior breaks when that behavior changes; the publisher, meanwhile,
believes it did not change the public interface. If the boundary of documented
behavior is not written down, disagreement is inevitable.

**A bug fix can break things too.** A patch release that fixes a wrong result breaks
code written around that wrong result. The contract still counts this as a patch.

**Human error exists.** The wrong part can be raised; a breaking change can be
published as a minor release.

For these three reasons, ranges are a convenience, not a guarantee. What gives
reproducibility is not the range but the lock file introduced in the previous lesson.
The range says "which upgrades I am prepared to accept"; the lock file records the
exact state of the installed tree. The two work together: the upgrade decision is made
as a deliberate action, the lock file is updated, and the tests are run.

The tool that makes the contract enforceable on the publisher's side is the
**deprecation** step: a capability slated for removal is first marked in a minor
release with a warning, the replacement path is documented, and only then removed in
the next major release. Skipping this step means the major version number announces
the upgrade but does not make the migration possible.

## Summary

- The three parts of a version number carry a promise: patch and minor upgrades
  announce that the code runs unchanged, a major upgrade that it may change.
- The subject of the promise is the package's public interface; a change to an
  encapsulated inner file does not raise the major version.
- Comparison is done numerically over the parts; a pre-release-tagged version is
  smaller than an untagged one, and build metadata does not enter precedence.
- The caret keeps the major version fixed, the tilde keeps the minor version fixed;
  for packages with a major version of zero these bounds shift one digit to the right.
- Ranges are a convenience, not a guarantee: the definition of a breaking change is
  ambiguous, and patch fixes can break things too. The lock file provides
  reproducibility.

## Next Step

Up to now, a single package's single range was examined. In a real project, dozens of
packages request the same dependency with different ranges, and these requests do not
always intersect. The next lesson shows what happens when they do not: duplicate
versions in the tree, two separate instances of the same library, and the identity
problems this duality produces.
