How to upgrade to npm packages with breaking changes?
A whole lot of features rolled out in ES6 and this caused a lot of npm packages to release new versions that either had breaking changes or added async support rather than using callbacks, and the upgrade to these made absolute sense when everybody started converting their code using nasty callbacks to the nicer and cleaner async syntax. Say you have already managed to upgrade your package anyhow to not lose any new functionality the new versions had to offer. The world of Javascript is a developing one and another such major release of features can once again trigger breaking changes. In this article, we will discuss, how to structure the usage of npm packages in your project/codebase so that you can upgrade to newer versions with the least possible development and testing effort.
Do you have an npm package that has been widely used in your project that you were long ignoring to update but now has a major stable release with all the security and bug fixes and the async version that you now have to upgrade to? But the package is so extensively used that updating it all at once will be a nightmare, not only for the developers but also for the testing team. To top it all, if your package is used directly in code haywire without having any dedicated module/file for it, then you are looking at pretty large changes in your codebase.
The right way to handle such a mess is always an iterative approach, a few changes at a time.
Assume that some package foo
is being used in the project which has to be updated from the version 1.4.0
to 2.1.3
.
// ES6 import syntax
// import foo from 'foo';const foo = require('foo');
foo
will correspond to one version of the package only in this case 1.4.0
. But you begin to wonder that to update iteratively, some part of your codebase has to use the existing installed version of the package and the other new one, but how it is even possible to use two versions of the same package at a time? You know you can’t use the two versions of the same package by the above import/require statement.
How to use two versions of a package in your code?
Installing a specific version of a package
npm install <name>@<version>
For instance, this command installs the exact version 2.1.3
the package foo
npm install foo@2.1.3
This updates the foo package to version 2.1.3 and you still cannot have co-existing old and newer versions of the package.
Installing a package under a custom name (alias)
npm install <alias>@npm:<name>
For instance, this command installs the package foo
under the custom name foobar
npm install foobar@npm:foo
Now combine both of these to install a specific version of a package under a custom name—
npm install <alias>@npm:<name>@<version>
npm i foo-2.1.3@npm:foo@2.1.3
This installs the version 2.1.3
of foo
under a new name i.e.foo-2.1.3
, so that you can use it independently without affecting the original package used.
const foo_213 = require('foo-2.1.3');
Now the package required/imported as foo
in the code everywhere uses the already installed version 1.4.0
of the package foo
, and any new piece of code can use foo-2.1.3
to use the newer version - 2.1.3
of the package.
Handling Breaking Changes
Ideally, there should be a helper module/file for using npm packages to avoid changes in the whole codebase in the event of breaking changes in newer releases.
The helper file for each package will contain functions (wrappers) that will call the underlying API of the package. Any new functionality introduced in the newer version will map to a new function in this module, and any breaking changes can be handled easily by changing just this file without having to inflict changes in the whole codebase.
For any new usages in the code where usage of this package is required, you should instead require this newly created helper module/file and try to iteratively shift your old code to use this module.
If you are considering the installation of a new package for a project, it is best to use it from dedicated helper modules to avoid such instances.
PRO TIPS:
Save exact versions of packages
Javascript developers tend to simply run this command to install a new package.
npm i foo --save
Be aware that this adds the dependency of foo
in package.json as -
{ "name": "baz-bar", .... "dependencies": { .... foo: "^2.1.3",
...}
The carat (^) means that the package will update to minor updates or patches, if available. The next time you have to do an npm install for setting up the environment for your project, there is a high chance that some package gets automatically updated. Any updates to npm packages should be tested and to ensure developers have the same local environment, the installation of the exact version of a package is highly preferred.
npm i foo --save-exact
# OR
npm i foo@2.1.3 --save-exact
Verify version before installation
Before installing or updating to the latest version blindly, always check the versions tab on the npm description page to check popular versions, the ones with the highest downloads. It is not always the case that the latest version is popular, and can have many bugs.
Commit package-lock.json
Developers often ignore to commit changes in package-lock.json or have package-lock.json git ignored. This is NOT recommended.
npm ci
Run npm ci
🚀 instead of npm install
🐢 when setting up your project in a new environment. Thank me later.
I have tried to share the best practices I have discovered during my experience of handling updates to packages with breaking changes in newer versions of npm packages in large Node.js projects. Let me know in the comments, I would like to know your experiences as well.
Acknowledgements
I express my gratitude to my mentor and guide — Dilraj Singh, with whose help a strategy was orchestrated to iteratively upgrade to the latest stable version of MongoDB Server and an update to npm package mongodb was a part of required codebase changes.