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
andBar@1
- The
Foo
package has a dependency onBaz
andQux
- The
Baz
package has a peer dependency onBar
- For simplicity, let's say that
Baz
andQux
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 onBar@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)
Interesting article indeed!
Only thing I'm missing here is how to compare the
babel-preset-arcanis
example to the generalized example withFoo
,Bar
etc..Which role would
babel-preset-arcanis
take in the generalized example? Would it be the app orFoo
orBar
orBaz
or .. ?Agree, I fail to see the problem with
babel-preset-arcanis
.