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
FooandBar@1 - The
Foopackage has a dependency onBazandQux - The
Bazpackage has a peer dependency onBar - For simplicity, let's say that
BazandQuxcannot 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
Quxpackage 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-arcanisexample to the generalized example withFoo,Baretc..Which role would
babel-preset-arcanistake in the generalized example? Would it be the app orFooorBarorBazor .. ?Agree, I fail to see the problem with
babel-preset-arcanis.