DEV Community

Maël Nison
Maël Nison

Posted on • Edited on

Implicit transitive peer dependencies

TL;DR: If you write a package that depends on Foo, and if Foo has a peer dependency, then you must provide it in either of the dependencies or peerDependencies fields. You won't "implicitly inherit" the peer dependencies declared in Foo.


Peer dependencies are a fickle beast. Sometimes powerful since they allow us to pick ourselves the version of a package we want to use, and sometimes annoying as they trigger a bunch of "unmet peer dependency" errors (btw, Yarn now supports optional peer dependencies! ;). They also have some corner cases, and it's one of them we're going to talk about today.

Imagine you're writing a preset for Babel. Your preset depends on babel-plugin-proposal-class-properties which is super userful. Nice! This is what your package.json will look like:

{
  "name": "babel-preset-arcanis",
  "dependencies": {
    "@babel/plugin-proposal-class-properties": "^7.3.3"
  }
}

And you publish this to the npm registry, and all is good. Right? Wrong! See, you've forgotten a small detail. Let's see the package.json for babel-plugin-proposal-class-properties@7.3.3 to figure out the problem.

{
  "name": "@babel/plugin-proposal-class-properties",
  "version": "7.3.3",
  "...",
  "peerDependencies": {
    "@babel/core": "^7.0.0-0"
  }
}

Ah! Unknowingly to us, babel-plugin-proposal-class-properties has a peer dependency on @babel/core, and we're not providing it! Now, I already hear you: "but my dear Maël, @babel/core is meant to be provided by the user of our preset and as such we don't need to list it - the package manager will figure it out". It sounds logical indeed, but there's a flaw in your plan.


Let's put our Babel example aside for a second, and let's consider a slightly different case. Imagine the following situation:

  • Your application has a dependency on Foo and Bar@1
  • The Foo package has a dependency on Baz and Qux
  • The Baz package has a peer dependency on Bar
  • For simplicity, let's say that Baz and Qux cannot be hoisted (in a real case scenario, this would typically be because their direct ancestors happen to depend on incompatible versions).

Now let's unravel what happens. Again for simplicity, let's imagine we're in an old style, non-PnP, environment (ie a big node_modules). In this situation we're going to end up with something similar to the following:

./node_modules/bar@1
./node_modules/foo
./node_modules/foo/node_modules/baz
./node_modules/foo/node_modules/qux

So: is Baz able to access the version of Bar provided by your application? "Well yes, of course", I hear you say, "Ergo, checkmate, I win, and you owe me five bucks." Not so fast. let's talk a bit about this Qux fellow. In fact, let's add the following requirement:

  • The Qux package has a dependency on Bar@2

It doesn't sound much, but how will it change the layout of our packages on the disk? Well, quite a bit. See, because Bar@1 (required by our application) and Bar@2 (required by Qux) cannot be merged, the package manager will find itself in a situation where Bar can only be hoisted one level up (inside Foo):

./node_modules/bar@1
./node_modules/foo
./node_modules/foo/node_modules/baz
./node_modules/foo/node_modules/bar@2
./node_modules/foo/node_modules/qux

See? Our Bar@2 packages appeared in foo/node_modules/bar - it couldn't be hoisted any further! And what it entails is simple: now, instead of Baz being able to require Bar@1 as you maybe expect, it will instead use Bar@2 that has been hoisted from the Qux dependencies.

I hear you, once again: "ok, but the package manager should figure out that since there's a transitive peer dependency in Foo, then Bar@2 should not be hoisted into it". You're starting to ask a lot from the package manager, aren't you? And the answer isn't that simple. See, some packages might rely on the broken behavior (as in, they would expect Qux to get Bar@2). Changing this would actually be a breaking change - on top of being a funny problem algorithmically speaking (funny story for another time).


So let's go back to our Babel example. What's the answer? What should we do to avoid issues such as the one described above? What sacrifice must be do to appease the Old Gods? Fortunately, it's much simpler:

{
  "name": "babel-preset-arcanis",
  "dependencies": {
    "@babel/plugin-proposal-class-properties": "^7.3.3"
  },
  "peerDependencies": {
    "@babel/core": "^7.0.0"
  }
}

See what I've done? I've just listed @babel/core as one of our dependencies. Nothing more, nothing less. Thanks to this, the package manager is now fully aware of what behavior to adopt: since there is a peer dependency on @babel/core, it is now forbidden to hoist it from a transitive dependency back to the level of babel-preset-arcanis 👌

Top comments (2)

Collapse
 
ysfaran profile image
Yusuf Aran

Interesting article indeed!

Only thing I'm missing here is how to compare the babel-preset-arcanis example to the generalized example with Foo, Bar etc..

Which role would babel-preset-arcanis take in the generalized example? Would it be the app or Foo or Bar or Baz or .. ?

Collapse
 
oliviertassinari profile image
Olivier Tassinari • Edited

Agree, I fail to see the problem with babel-preset-arcanis.