Warm-up · Activity 1 of 7
// 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.
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
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" } }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"Practice · Activity 4 of 7
Match each range in package.json to the versions it accepts.
Practice · Activity 5 of 7
Which of these versions does this range accept?
"dep1": "^0.2.3"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"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.jsOutput
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
Hint 1
A regular expression with ^ and $ can require the whole text to be digits, a dot, digits, a dot, digits: /^(\d+)\.(\d+)\.(\d+)$/.
Hint 2
exec() returns null when the text does not match; return null then.
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.jsRun the checks (needs learnrun.js in the same folder):
node --testDownload learnrun.jsExercise 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
Hint 1
range.slice(1) removes the ^, and parseVersion turns both strings into numbers.
Hint 2
A different major is never allowed. With the same major, a higher minor is always allowed.
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.jsRun the checks (needs learnrun.js in the same folder):
node --testDownload learnrun.jsCommon 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 fromWhy, 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.