How to Use Semantic Versioning in NPM

The Node Package Manager (npm) ecosystem uses Semantic Versioning, or SemVer, as the standard for version numbers. By default, when installing an npm package without specifying a version, npm installs the latest version published to the NPM registry. Because we don't store our node_modules/ folder in version control, the actual code of our dependencies, and reproducibility of the dependency graph, is tied to a version number.

In this tutorial we'll:

  • Learn about package version management and why it's important.
  • Understand how packages and dependencies are versioned in Node Package Manager.
  • Learn about dependency drift and how to prevent it.

By the end of this Node.js tutorial, you should understand how Semantic Versioning is used, what version constraints are, and what the role of the package-lock.json file is.

Goal

Understand how version numbers are used by npm.

Prerequisites

Watch: What is Semantic Versioning (SemVer)?

Understanding package versions

When you install a package without specifying a version like npm install request, npm downloads the latest version of the package to your node_modules/ folder. A corresponding name and version number entry are added to the dependencies field of your package.json.

Example package.json dependency entry:

"dependencies": {
    "request": "^2.88.0"
}

The version number recorded is composed of a rule symbol (the caret ^) and a version number in semantic versioning format.

The NPM ecosystem uses Semantic Versioning (SemVer) which follows the convention of MAJOR.MINOR.PATCH (e.g. 1.3.2). This is to differentiate between versions that introduce major breaking changes, minor backwards-compatible changes, and patch changes for small fixes.

When a package author publishes a new version of their package to NPM, they are prompted to bump the version number according to the nature of the update. Bug fixes, typos, and other small changes should be a patch version, adding functionality a minor version, and breaking things a major version.

Most of the time though, this is abstracted away, and you can run npm update to bring all dependencies to their latest compatible versions. We are able to do that because of the combination of a rule symbol and a semantic version number informs npm update what versions are compatible with our package.

What's the caret ^ for?

By default, npm prefixes a caret ^ before the version number of an installed dependency. This character, and others, are rule symbols which indicate to npm how to handle future package updates.

The caret specifies we can update to any version greater than 2.88.0 within this major version. In the above example, the major version is 2. Anything > 2.88.0 and < 3.0.0 is valid. However, the caret behavior changes when a package version is still below 1.0.0.

Note: When a package version is below 1.0.0 (e.g. ^0.2.5), a caret will only let us update patch versions, and not minor or major versions.

In theory, this allows us to safely upgrade to versions that are backward-compatible and not considered breaking changes.

Other shorthand symbols you can use to specify version ranges in different ways are ^, ~, >=, <, ||.

By far the most common is the caret, but the other symbols are useful as well. If you'd like to learn more about them https://semver.npmjs.com/ provides an a great interactive example.

How version drift occurs

Version drift is when the version numbers of your application's dependencies is different across different developers, builds, or other environments. This can lead to hard-to-debug inconsistencies between environments your application runs in, because it isn't immediately clear what versions of dependencies are installed.

To combat this issue, version 5 of npm added the package-lock file which serves as a snapshot of all your installed dependencies, their associated versions, and the versions of all those dependencies' own dependencies. It is managed by npm when we install, update, or otherwise change our dependencies.

Without a package-lock.json file, when you npm install the dependencies for an existing project, npm will install the latest available version of each dependency allowed by the version prefix in package.json.

If a version of a package is tagged in package.json to ^2.0.0, and the latest version is 2.88.0, version 2.88.0 will be installed to your node_modules/. This can easily lead to dependency drift, where different developers or environments all have slightly different versions of dependencies installed.

Ultimately, we want to ensure that everyone on a team is working with the same exact set of dependencies in their local environments, CI/CD, and deployments. The simplest way to do that is to commit the package-lock.json file to version control along with package.json.

Lock version numbers

When there is a package-lock.json file present, npm will install the exact same dependency tree as what's described by the package-lock.json. The dependency tree is a representation of all the modules in your node_modules/ folder, and their relationships to each other. This is extremely helpful in managing changes to underlying dependencies, and ensuring you're using the exact same dependency tree across developers, continuous integration builds, deployments, and everywhere else.

Because you're intended to check package-lock.json into version control along with your code, you'll be able to time travel to different states of your node_modules/ directory without checking the directory itself into version control.

Put plainly, package-lock.json records the exact versions of not only your dependencies, but the sub-dependencies of your dependencies. This prevents version drift, and when the package-lock.json is present in a project's root, npm install will respect the exact versions it descibes.

Recap

In this tutorial we learned that Semantic Versioning dictates how a packages version number is incremented by following the MAJOR.MINOR.PATCH format. We learned about how npm uses rule symbols combined with SemVer in the package.json file to determine what version(s) of package are eligible to install. And we learned about how the package-lock.json file is used to prevent version drift and ensure everyone is using the exact same version of a package.

Further your understanding

  • Can you explain what the difference is between the ^ and ~ version contraints?
  • Explain the reasoning for commiting the package-lock.json file to version control.

Additional resources

Sign in with your Osio Labs account
to gain instant access to our entire library.

Data Brokering with Node.js