Skip to content
aviral gupta

// B2.3 · ~32 min · Beginner

semver ranges and updates

After this lesson you can read a version number, write the range you mean in package.json, and update dependencies knowing what changes.

Lesson 3 of 5 in B2 npm and packages

You will be able to

  • Read major.minor.patch: which number a fix, a feature or a breaking change raises, and how prereleases sort
  • Predict what exact versions, ^, ~, x-ranges and prerelease ranges accept, also below 1.0.0
  • Use npm outdated and npm update, and say what each changes
  1. Warm-up · Activity 1 of 7

    Warm-up: a library at version 2.3.1 adds a new function, and everything that worked before still works. Which version should the release have?

  2. Predict · Activity 2 of 7

    Predict before you read on. dep1 1.1.1 is installed with the package.json below. Since then, dep1 1.1.2, 1.2.0 and 1.2.2 were published, and 1.2.2 is tagged latest. You run npm update. Which version of dep1 is installed afterwards?

    {
      "dependencies": {
        "dep1": "~1.1.1"
      }
    }
  3. Practice · Activity 3 of 7

    Fill in the operator so that the range accepts 1.4.2 and 1.9.0, but not 2.0.0.

    "dep1": "____1.4.2"
    "dep1": "1.4.2"
  4. Practice · Activity 4 of 7

    Match each range in package.json to the versions it accepts.

  5. Practice · Activity 5 of 7

    Which of these versions does this range accept?

    "dep1": "^0.2.3"
  6. Brain teaser · Activity 6 of 7

    Brain teaser. The registry has dep1 1.4.0-beta.1, 1.4.0-beta.3, 1.4.0, 1.4.7 and 1.5.0-beta.1. Which version does npm install choose for this range?

    "dep1": "^1.4.0-beta.2"
  7. Apply · Activity 7 of 7

    Mini-task. In a new folder, run npm init -y. Pick a small package and list its versions with npm view <package> versions. Install an older one with npm install <package>@<version> and look at the range npm saved in package.json. Run npm outdated and read Current, Wanted and Latest. Run npm update, then compare package.json and package-lock.json with before. Finally move to the newest major with npm install <package>@latest.

    Check your work against this list

Build it yourself

Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.

Worked example

Versions compare as numbers

Before npm can pick the highest version a range allows, it must know which version is higher. This program sorts four versions twice: as text, which is what sort() does by default, and part by part as numbers, which is how semver compares them. As text, 1.10.0 comes first, because the character 1 sorts before 9. It runs in the browser too.

main.js

// Versions are compared number by number, not letter by letter.
const versions = ["1.9.0", "1.10.0", "1.2.10", "1.2.9"];

function compare(a, b) {
  const x = a.split(".").map(Number);
  const y = b.split(".").map(Number);
  for (let i = 0; i < 3; i++) {
    if (x[i] !== y[i]) return x[i] - y[i];
  }
  return 0;
}

console.log("as text:   ", [...versions].sort().join(" < "));
console.log("as numbers:", [...versions].sort(compare).join(" < "));
console.log("newest:", [...versions].sort(compare).at(-1));
console.log('"1.10.0" > "1.9.0" is', "1.10.0" > "1.9.0");

Run it with

node main.js

Output

as text:    1.10.0 < 1.2.10 < 1.2.9 < 1.9.0
as numbers: 1.2.9 < 1.2.10 < 1.9.0 < 1.10.0
newest: 1.10.0
"1.10.0" > "1.9.0" is false
  • sort() without a function compares strings character by character, so "1.10.0" lands before "1.2.10".
  • compare() looks at major, then minor, then patch, and only moves on while they are equal.
  • This compare() knows no prereleases: "0-beta" is not a number. npm's semver package handles them.
Change it and run it

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.

Exercises

Exercise 1 of 2

Read a version

Write parseVersion(text). For a version of three whole numbers separated by dots, such as "1.4.2" or "10.20.30", it returns {major, minor, patch} as numbers. For anything else it returns null: a range such as "^1.4.2", a partial version such as "1.4", a leading v as in "v1.4.2", four parts, or a prerelease such as "2.0.0-beta.1" (this parser leaves those out). Run the tests with node --test.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.

Hints
  1. Hint 1

    A regular expression with ^ and $ can require the whole text to be digits, a dot, digits, a dot, digits: /^(\d+)\.(\d+)\.(\d+)$/.

  2. Hint 2

    exec() returns null when the text does not match; return null then.

  3. Hint 3

    The groups are strings. Number(match[1]) turns the first into a number.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

// {major, minor, patch} for a version such as "1.4.2", or null.
export function parseVersion(text) {
  const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(text);
  if (match === null) return null;
  return {major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3])};
}
Run it on your computer

Install Node.js 24 LTS or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

// {major, minor, patch} for a version such as "1.4.2", or null.
export function parseVersion(text) {
  return null;
}

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {parseVersion} from './main.js';

test('parses 1.4.2 into three numbers', () => {
  assert.deepEqual(parseVersion('1.4.2'), {major: 1, minor: 4, patch: 2}, `parseVersion('1.4.2') returned ${JSON.stringify(parseVersion('1.4.2'))}`);
});

test('reads numbers with more than one digit', () => {
  assert.deepEqual(parseVersion('10.20.30'), {major: 10, minor: 20, patch: 30}, `parseVersion('10.20.30') returned ${JSON.stringify(parseVersion('10.20.30'))}`);
});

test('returns null for ranges and other text that is not a plain version', () => {
  for (const text of ['^1.4.2', '1.4', 'v1.4.2', '1.4.2.0', '2.0.0-beta.1', '1.x.0', '']) {
    assert.equal(parseVersion(text), null, `parseVersion(${JSON.stringify(text)}) returned ${JSON.stringify(parseVersion(text))}, expected null`);
  }
});

package.json

{
  "type": "module"
}

package.json tells Node.js that the .js files are modules; keep it in the folder.

Run the program:

node main.js

Run the checks (needs learnrun.js in the same folder):

node --test
Download learnrun.js

Exercise 2 of 2

A caret range, by hand

parseVersion is done. Write allowsCaret(range, version) for a caret range whose major is 1 or more, such as "^1.4.2": it returns true when version has the same major and is not lower than the range's version. So "^1.4.2" allows 1.4.2, 1.9.0 and 1.10.0, but not 1.4.1, 1.3.9 or 2.0.0. Compare the parts as numbers. Ranges below 1.0.0 and prereleases are out of scope; npm's semver package handles those.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.

Hints
  1. Hint 1

    range.slice(1) removes the ^, and parseVersion turns both strings into numbers.

  2. Hint 2

    A different major is never allowed. With the same major, a higher minor is always allowed.

  3. Hint 3

    With the same major and minor, the patch must be equal or higher.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

// {major, minor, patch} for a version such as "1.4.2", or null.
export function parseVersion(text) {
  const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(text);
  if (match === null) return null;
  return {major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3])};
}

// true if version is allowed by a caret range such as "^1.4.2" (major 1 or more).
export function allowsCaret(range, version) {
  const base = parseVersion(range.slice(1));
  const v = parseVersion(version);
  if (v.major !== base.major) return false;
  if (v.minor !== base.minor) return v.minor > base.minor;
  return v.patch >= base.patch;
}
Run it on your computer

Install Node.js 24 LTS or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

// {major, minor, patch} for a version such as "1.4.2", or null.
export function parseVersion(text) {
  const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(text);
  if (match === null) return null;
  return {major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3])};
}

// true if version is allowed by a caret range such as "^1.4.2" (major 1 or more).
export function allowsCaret(range, version) {
  return false;
}

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {allowsCaret} from './main.js';

test('allows the version itself and newer minors and patches', () => {
  for (const v of ['1.4.2', '1.4.9', '1.9.0']) {
    assert.equal(allowsCaret('^1.4.2', v), true, `allowsCaret('^1.4.2', '${v}') should be true`);
  }
});

test('compares numbers, not text: 1.10.0 is newer than 1.4.2', () => {
  assert.equal(allowsCaret('^1.4.2', '1.10.0'), true, `allowsCaret('^1.4.2', '1.10.0') should be true`);
});

test('rejects lower versions', () => {
  for (const v of ['1.4.1', '1.3.9', '0.9.9']) {
    assert.equal(allowsCaret('^1.4.2', v), false, `allowsCaret('^1.4.2', '${v}') should be false`);
  }
});

test('rejects a new major', () => {
  assert.equal(allowsCaret('^1.4.2', '2.0.0'), false, `allowsCaret('^1.4.2', '2.0.0') should be false`);
  assert.equal(allowsCaret('^2.0.0', '2.5.1'), true, `allowsCaret('^2.0.0', '2.5.1') should be true`);
});

package.json

{
  "type": "module"
}

package.json tells Node.js that the .js files are modules; keep it in the folder.

Run the program:

node main.js

Run the checks (needs learnrun.js in the same folder):

node --test
Download learnrun.js

Common mistakes

Reading a range as if it were a version

// {major, minor, patch} for a version such as "1.4.2", or null.
function parseVersion(text) {
  const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(text);
  if (match === null) return null;
  return {major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3])};
}

const dependencies = {dep1: "^1.4.2"};
console.log("dep1 major:", parseVersion(dependencies.dep1).major);

What Node.js prints

TypeError: Cannot read properties of null (reading 'major')

Why, and the fix

"^1.4.2" in package.json is a range, not a version, so the parser returns null and .major fails. The version that is really installed is in package-lock.json and in node_modules/dep1/package.json. Check for null before you use a result, and use a range only with code that understands ranges, such as the satisfies() function of npm's semver package.

Importing semver without installing it

import semver from "semver";

console.log(semver.satisfies("1.9.0", "^1.4.2"));

What Node.js prints

Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'semver' imported from

Why, and the fix

npm bundles its own copy of semver, but that copy lives inside npm, not in your project, so Node.js cannot find it. Install it into the project first: npm install semver. That adds it to "dependencies" and to node_modules, and then the import works and prints true.

A parser that forgets prereleases

// The three numbers of a version such as 1.4.2.
function parts(version) {
  return version.match(/^(\d+)\.(\d+)\.(\d+)$/).slice(1).map(Number);
}

console.log(parts("1.4.2"));
console.log(parts("2.0.0-beta.1"));

What Node.js prints

TypeError: Cannot read properties of null (reading 'slice')

Why, and the fix

2.0.0-beta.1 is a valid version: a prerelease with the tag beta.1. The pattern allows three numbers only, so match() returns null, and .slice fails. Either allow a tag after a hyphen, as in /^(\d+)\.(\d+)\.(\d+)(-[0-9A-Za-z.-]+)?$/, or check for null and report the version you cannot read. Remember that 2.0.0-beta.1 sorts before 2.0.0.

JavaScript in the browser: your browser’s own engine, in a sandboxed worker. Syntax errors are located with acorn 8.18.0, MIT. Licence and source

Exit ticket

5 questions, no hints. Score 80% or more to complete the lesson.

Finish every activity above to unlock the exit ticket.

Report a problem

Spotted something wrong or unclear? Say what, and it will be checked and fixed.

#

At least 20 characters.

Only if you want a reply.

Key ideas

major.minor.patch says what changed

A version such as 1.4.2 has three numbers: major, minor and patch. npm asks authors to raise the patch for a backward-compatible bug fix (1.4.3), the minor for a backward-compatible new feature, resetting the patch (1.5.0), and the major for a change that breaks compatibility, resetting both (2.0.0). npm recommends starting at 1.0.0. The parts compare as numbers, so 1.10.0 is newer than 1.9.0, although "1.10.0" < "1.9.0" as strings. A prerelease adds a tag after a hyphen, such as 2.0.0-beta.1, and sorts before 2.0.0. In package.json, "dependencies" maps each package name to a range of versions, not to one version.

What a range lets in

An exact version, "1.4.2", allows only 1.4.2. A caret, "^1.4.2", allows 1.4.2 up to below 2.0.0: new minors and patches, never a new major. A tilde, "~1.4.2", allows patches only: up to below 1.5.0. An x-range fills in what is missing: "1.4.x" and "1.4" mean 1.4.0 up to below 1.5.0, "1.x" and "1" up to below 2.0.0, and "*" any version. Below 1.0.0 the caret keeps the left-most non-zero number fixed: "^0.2.3" allows 0.2.x only, and "^0.0.3" only 0.0.3. Prereleases stay out unless the range names a prerelease of the same major.minor.patch: "^1.2.3" does not accept 1.3.0-beta.1.

npm outdated reports, npm update installs

npm outdated lists each dependency that is behind: Current is installed, Wanted is the highest version your range allows, Latest is the version the registry tags latest. It exits with code 1 when something is behind. npm update installs Wanted: it changes node_modules and package-lock.json, and leaves the range in package.json as it is; npm update --save rewrites that too, so "^1.1.1" becomes "^1.2.2". Neither goes past your range. For a new major, install it: npm install dep1@latest saves "^2.0.0". npm install dep1@1.1.1 saves "^1.1.1", npm's default operator; add --save-exact to save exactly "1.1.1".

Sources

Last reviewed September 30, 2026