Darek Kay's picture Darek Kay

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 only kleur@4.0.0
  • yarn installs both kleur@4.0.0 (main package) and kleur@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.


Related posts

Want to leave a comment?

Join the discussion at Twitter or Mastodon. Feel free to drop me an email. 💌

Npm vs. Yarn: Dependency resolution