package.json file lists what your project needs
It all starts with the package.json file. This file lists all node packages that your projects depends on. This file can specify versions in three different ways:
|7.2.0||Install exactly version 7.2.0|
|~7.2.0||Install the latest 7.2.* version|
|^7.2.0||Install the latest 7.*.* version|
When you run
npm install to install all required packages, npm will determine at that time which versions of packages to install. This means that there may be differences in the installed package versions, depending on the time that you run
npm install. Let’s look at an example.
package.json file specifies that my project requires version
~7.2.0 (so any 7.2.* version) of some package. When I run the command today, 7.2.0 is the latest available version, and that’s what I will get. The next day, the authors of this package update their package to version 7.2.1. Now the day after, when my colleague runs
npm install on the exact same
package.json file, he will get version 7.2.1 of that package.
Minor differences between versions could break the build
As you can see, this can be quite a ‘relaxed’ way of specifying versions. This has the potential benefit of receiving minor upgrades and security patches of your dependencies, without requiring any configuration change on your part. That’s a good thing, and during active development, that’s probably what you want. Besides, theoretically, minor and patch updates should never contain any breaking changes…
However, we don’t live in a perfect world, and sometimes even the smallest update of a package can introduce problems. So if your build server installs a slightly different version than your development laptop, things may suddenly and unexpectedly break. So how do we deal with this?
We could resort to only specifying exact versions in our
package.json file. But this has two drawbacks. First, we lose the benefit of easy updates to minor or patch versions. And secondly, we still cannot control our dependencies’ dependencies.
Let’s say we depend on version 1.1.0 of Package A, and Package A in turn depends on version
^2.1.1 of Package B. Notice the
^ in front: package A only depends on the major version, any version 2.* will be allowed. In this scenario, we cannot guarantee which version of Package B we get. We might get version 2.1.1, but it could also be 2.1.8 or 2.3.0, since Package A only specified a dependency on the major version number. So while we have locked down our direct dependency, we still don’t control the entire package tree.
Fortunately, there are some mechanism to lock down the exact versions. Let’s see how that works.
The package-lock.json file “logs” which versions were installed
When you run
npm install, npm creates a
package-lock.json file. The name is somewhat confusing, as it might suggest that this file somehow “locks down” which version of packages gets installed. Instead, it is best thought of as a “log”: a file that lists which exact versions of packages were installed at the time. When you run
npm install again after a few days or weeks, when new versions of some packages are available, npm will update the existing
package-lock.json file to create an updated log of the installed package versions.
Note that the
package-lock.json lists all packages that are installed. So not just the primary packages on which your project depends, but also of those packages’ dependencies. In this way, the
package-lock.json file is an exact manifest of all packages that were installed at a certain time. This is very helpful! It means that when we have a working installation on our development laptop and we commit the corresponding
package-lock.json file, we at least have a ‘recipe’ that can guarantee a working configuration.
Now we just need one more missing piece: how can we restore a working installation from this committed
npm ci to install exact package versions from the lock file
That’s where the
npm ci command comes in.
> npm ci
This command is similar to
npm install, but it will look at the
package-lock.json file instead of the
package.json file, and it will install the exact versions that are listed in that file. This way, you can guarantee that your automated build environment will install the exact same versions that you installed on your dev machine.
And as a nice bonus: the
npm ci command is actually about twice as fast as the regular
npm install command, so you get a nice optimization of your automated build pipeline for free!
Hopefully, these tips help you to get some measure of control over what could quickly become npm dependency hell. If you have other tricks to keep your npm versioning under control, let them know in the comments!