Npm vs. Yarn: Dependency resolution
Both npm and Yarn support dependency version ranges (e.g. ^4.1.1
). However, there are some differences in how package managers resolve dependencies, which might lead to inconsistencies between different environments.
In this post I will present the behavior of npm 7.15.1
and Yarn 1.22.4
/ 3.0.2
.
Changing version ranges
I've set up two projects with the same package.json
file:
"dependencies": {
"kleur": "4.x.x"
}
The version range 4.x.x
will be satisfied by any major 4
version, e.g. 4.0.0
, 4.1.0
or 4.1.4
. In a project without any lock file (package-lock.json
, yarn.lock
), npm install
/ yarn install
installs the latest kleur
version that satisfies our range (4.1.4
at the time of writing):
// package-lock.json
"dependencies": {
"kleur": {
"version": "4.1.4"
}
}
// yarn.lock
kleur@4.x.x:
version "4.1.4"
Let's now downgrade the dependency version to 4.0.0
:
"dependencies": {
"kleur": "4.0.0"
}
After another install
, both package managers switch to version 4.0.0
. This makes sense, as our installed version 4.1.4
does not match 4.0.0
:
// package-lock.json
"dependencies": {
"kleur": {
"version": "4.0.0"
}
}
// yarn.lock
kleur@4.0.0:
version "4.0.0"
Now it will get interesting. Let's switch back to 4.x.x
:
"dependencies": {
"kleur": "4.x.x"
}
That's where the package managers handle things differently:
// package-lock.json
"dependencies": {
"kleur": {
"version": "4.0.0"
}
}
// yarn.lock
kleur@4.x.x:
version "4.1.4"
Npm compares the installed version 4.0.0
with the new version range 4.x.x
and finds a match. Hence, the installed version remains at 4.0.0
.
On the other hand, Yarn installs 4.1.4
again. I assume that Yarn cannot find a definition "kleur@4.x.x"
in yarn.lock
, so it treats kleur
as if it were a new dependency.
In short, npm matches the actual version, while Yarn matches the version definition. I think this makes Yarn a little more consistent: the result is the same as when deleting yarn.lock
.
Workspaces
If you are using workspaces, the resolution differences between npm
and yarn
will cause even more confusion.
Let's set up a basic workspace project:
├─ 📁 workspace-a
| └─ 📄 package.json
└─ 📄 package.json
./package.json
:
"dependencies": {
"kleur": "4.0.0"
},
"workspaces": ["workspace-a"]
./workspace-a/package.json
:
"dependencies": {
"kleur": "4.x.x"
}
In this scenario, we get deviations right after the first install
:
npm
installs onlykleur@4.0.0
yarn
installs bothkleur@4.0.0
(main package) andkleur@4.1.4
(workspace)
// package-lock.json
"dependencies": {
"kleur": {
"version": "4.0.0"
}
}
// yarn.lock
kleur@4.0.0:
version "4.0.0"
kleur@4.x.x:
version "4.1.4"
When we look into the directory tree of the Yarn v1 project, we can indeed see two distinct kleur
versions:
├─ 📁 node_modules
| └─ 📁 kleur (4.0.0)
├─ 📁 workspace-a
| ├─ 📁 node_modules
| | └─ 📁 kleur (4.1.4)
| └─ 📄 package.json
├─ 📄 package.json
└─ 📄 yarn.lock
I guess Yarn enforces consistency by treating workspaces as separate projects, but npm's behavior still feels more reasonable to me.
Conclusion
Differences between a local development environment and a build pipeline can lead to all kinds of issues. One solution is dependency pinning, i.e., using fixed dependency versions. When using version ranges instead, lock files promise to solve the consistency problem. However, there are non-obvious differences between npm and Yarn to keep in mind.