npm and Semantic Versioning

If you've been a developer for any amount of time you've undoubtedly reached for external dependencies to solve specific problems. Whether the problem is as small as padding the left side of a string or as large as a component library, external packages are inevitable. In this post, I'll be going into the nuances of how npm leverages semantic versioning and some information that will be useful as npm package consumers. Let's dive in!

Aaron Bos | Wednesday, January 17, 2024

Semantic Versioning Intro

Before getting too deep into npm package specifics, it's worth taking a look at semantic versioning in general. If you're not familiar with semantic versioning (sometimes referred to as "semver"), it is a simple set of rules and requirements that dictate how version numbers are assigned and incremented.

Typically you'll see semantically versioned packages formatted as X.Y.Z or Major.Minor.Patch where each period-separated part of the version is a positive integer without leading zeroes. For example, the first production release of any package should be 1.0.0.

Occasionally you may see slight variations of this standard. For example, packages are versioned with a major version of zero like 0.2.1. This indicates that a package is in initial development and that the entire API is subject to change. Another common variation of the standard is to append a modifier like alpha, beta, or build numbers to the end of a version. Both of these variations are compliant with the official semver standard.

For package creators, semantic versioning is most important when making changes to an existing package. The developer needs to decide what kind of version bump the change requires. Below is a list of scenarios to take into consideration when deciding how to change a package version.

  • Bump patch version: Bug fixes not affecting the API
  • Bump minor version: Backward compatible API additions/changes
  • Bump major version: Backward incompatible API changes

While the standard is relatively straightforward, I think going too deep into the specifics is out of scope for what I'm looking to cover in this post. If you're interested in learning more head over to the semver spec to get a more in-depth understanding of everything it entails.

package.json Specifics

All packages that are published to the npm repository are required to contain a package.json file. The package.json file serves the following purposes.

  • Lists the packages your project depends on
  • Specifies versions of a package that your project can use using semantic versioning rules
  • Makes your build reproducible, and therefore easier to share with other developers

There are many fields that can be included in the package.json file, but for this post, we are mostly concerned with the "dependencies" and "devDependencies" fields. The difference between the two is that devDependencies are packages that are only needed for local development or testing. Whereas the packages listed in the dependencies section are expected to be needed in production. To install a dev dependency the --save-dev flag should be used when running npm install for the package.

npm install <package_name> --save-dev

Both the dependencies and devDependencies sections include package names along with semantic version numbers. If you've seen package.json files before you may have noticed that the exact package version isn't always used when referencing packages. Within the semver spec we can specify package ranges for which the "best" package meeting the range's criteria will be selected for use.

  "dependencies": {
    "foo": "1.0.0 - 2.9999.9999",
    "bar": ">=1.0.2 <2.1.2",
    "baz": ">1.0.2 <=2.3.4"

Getting into node-semver

Now I'll be going through the different options that we have available when specifying package versions in the package.json file. Again these rules are compliant with the semver spec, which is managed in the node-semver package globally installed with npm.

  • 1.2.3 Specify the exact version of a package. If that package doesn't exist, an error will be thrown.
  • >1.2.3, >=1.2.3, <1.2.3, <=1.2.3 The comparison operators work exactly how you'd expect them to. The resolved package will be the next available version compared using the provided operator. The comparisons can also be combined to specify explicit ranges like >=1.2.3 <=3.2.1.
  • ~1.2.3 The tilde range resolves a package that is "approximately equivalent to" the specified version. This range behaves slightly differently depending on whether or not a Minor or Patch version is included in the requested version. For example, ~1.2.3 will allow patch-level changes greater than 1.2.3, but less than 1.3.0 whereas ~1 will allow minor or patch version upgrades up until 2.0.0.
  • ^1.2.3 Carat ranges allow patch and minor updates for versions 1.0.0 and above, patch updates for versions 0.X >=0.1.0, and no updates for versions 0.0.X. The updates allowed are based on the left-most zero in the specified version.
  • 1.2.x Allows for patch upgrades only
  • * or "" will match any version 😱
  • 1.2.3-3.2.1 is the same as >=1.2.3 <=3.2.1
  • 1.2.3-3.2.1 || 4.0.0-5.0.0 will pass as long as either of the ranges are met
  • HTTP, Git URLs, and local paths can also be referenced, but are less commonly used.

For a more hands-on look at semver resolution specifics, check out the semantic versioning calculator which provides a UI to play around with dependency versions and ranges. This is a quick intro into what can be a fairly complicated space. I hope it provides a foundation to learn more if you need to do so!

As always thank you for taking the time to read this blog post!