Why newer isn't always better

Twice in the past week I've seen projects I'm working on go off the rails when an automatic, seemingly minor, dependency update didn't do what it advertised.

First, it's helpful to understand something about how packages are versioned in the node.js/JavaScript ecosystem.  By and large, such projects use Semantic Versioning (semver) to describes version numbers in the following terms:

Given a version number MAJOR.MINOR.PATCH, increment the:
1. MAJOR version when you make incompatible API changes,
2. MINOR version when you add functionality in a backwards compatible manner, and
3. PATCH version when you make backwards compatible bug fixes.

As a result, version 2.3.4 has a distinct meaning from 2.3.5 (patch update) or 2.4.0 (minor update) or 3.0.0 (major update).

Second, when we npm install a dependency, we typically don't use absolute version numbers (although you certainly can!).  Instead, we typically use one of the following ways of defining a dependency version in package.json:

  • version Must match version exactly
  • >version Must be greater than version
  • >=version etc
  • <version
  • <=version
  • ~version "Approximately equivalent to version"  See semver
  • ^version "Compatible with version"  See semver
  • 1.2.x 1.2.0, 1.2.1, etc., but not 1.3.0
  • http://... See 'URLs as Dependencies' below
  • * Matches any version
  • "" (just an empty string) Same as *
  • version1 - version2 Same as >=version1 <=version2.
  • range1 || range2 Passes if either range1 or range2 are satisfied.
  • git... See 'Git URLs as Dependencies' below
  • user/repo See 'GitHub URLs' below
  • tag A specific version tagged and published as tag  See npm dist-tag
  • path/path/path See Local Paths below

Probably the most common ways that I see people use are ~1.2.3 and ^1.2.3.  The former is a tilde range, and the latter is a caret range.  Understanding what they mean is important.

  • 1.2.3 - use version 1.2.3 exactly.  Accept no subsititutes.
  • ~1.2.3 - use version 1.2.3 or any other compatible patch level version: 1.2.4, 1.2.5, ..., 1.2.9
  • ^1.2.3 - use version 1.2.3 or any other compatible minor or patch level version: 1.2.4, 1.2.9, 1.3.0, 1.4.6, 1.9.9

There's a real difference between using 1.2.3 and ^1.2.3.  Part of that difference is how much you trust the ecosystem to follow the rules of Semantic Versioning, and how well you think they've done interpretting what is and isn't a breaking change.

I said earlier that I'd been burned by semantic versioning recently, and I wanted to talk about one of the instances.  I was starting to work on fixing a bug in one of the repositories for diagrams.net.  I cloned the repo, built the code, and immediately crashed.  The error seemed to imply a version issue, so I tried adjusting my environment (surely it's me?)  No amount of fidgeting with my local dev environment would get rid of the crash, and I decided to turn my attention to figuring out what it was instead of fixing my actual bug.

It turned out that one of the dependencies had shipped an update 2 days previous from 1.2.1 to 1.2.2, and decided to introduce a startup exception if your version of Electron didn't meet some new criteria.  Using 1.2.1 was fine; but 1.2.2 meant you crashed into the ocean on startup. To repeat, that's moving from 1.2.1 to 1.2.2:

3. PATCH version when you make backwards compatible bug fixes.

Because the diagrams.net project was using version ^1.2.1, the compatible version 1.2.2 is a reasonable substitute.  Except that it wasn't.

I mention this because it's easy to get burned by inconsistent updates by upstream projects, who play fast and loose with major.minor.patch update numbers.  Understanding what you're signing up for when you pick a dependency is important.  So too is knowing what you mean when you ship an update to a project others are using.

Show Comments