# Addressing the shortcomings of peer dependencies Peer dependencies are intended to help reduce duplicate installs in applications by allowing libraries to opt out of introducing them, yet fall short to give control to app hosts on what they expect to be singletons. They also give no way for producers to indicate common intent. * They put singleton package expectations in the wrong hands (middle-tier libraries rather than app hosts and atomic libraries) * They are easy to misunderstand due to poor vocabulary (peer does not imply singleton) * They are difficult to resolve (so many unmet peer dependency warnings ignored...) * They add unnecessary burden on app consumers (e.g. installing emotion dependencies with mui) * Error messages don't make it easy to resolve them. ## The roles - who is involved? For this article we're going to refer to the following roles: * **App hosts**, which consume both the atomic and middle tier libraries. There's only one of these per app. :) * **Middle-tier libraries** which depend on atomic libraries and will be consumed by app hosts. Think `@mui/material`. There are MANY of these in an app graph. * **Atomic libraries** with few dependencies. Think `react` and `react-dom`. These are the low layer dependencies that app hosts and middle-tier libraries depend on, that we usually want deduped. For the sake of this writup we can also consider **consumers** and **producers** - Consumers define dependencies in their package.json, while producers write the code being depended on. * App hosts are only consumers. They consume both atomic and middle-tier libraries. * Middle-tier libraries are both producers and consumers. * Atomic libraries are producers and sometimes consumers, when they depend on other libraries. ## What tools do we have for defining dependencies? The `package.json` definition has a few ways to define the semver requirements of their dependencies: * `dependencies` - the most obvious straightforward collection; these dependencies * `peerDependencies` - almost the same as `dependencies` but not quite. We'll get into this in more detail next. * `devDependencies` - similar to `dependencies`, but the packages listed within will only be installed when installing directly. They will be ignored when the package is installed as a dependency. * `optionalDependencies` - dependencies that are architecture specific. * `resolutions` - a way for monorepo package managers to force a version to be resolved, regardless of the rules in place. It's a sledge hammer override that forces a situation that wasn't anticipated in semver requirements. ## What are peers intending to solve? Peer dependencies are a tool for the **middle tier libraries** to define their semver range requirement for something, but without actually requiring it to be installed. The intent is that the **app host** will resolve the dependency, allowing it to become a singleton in the graph. When the **app host** does not come up with a resolution that satisfies all peer dependency requirements, we get the classic "unmet peer dependency" warning. This ends up getting largely ignored, because people don't have any clue what that means or how to resolve it. npm 7 changes some things: now peer dependencies will be added by default when installing. However this still doesn't resolve the core question: "When should I use a dependency vs a peer?" ## When should you list a peer dependency rather than a regular dependency? This question illustrates the root of the problem. It's ambiguous to answer. Let's take some obvious scenarios: * **Middle tier library** consuming `react`. We implicitly agree `react` should have 1 copy; otherwise you are likely to run into issues with context. Should `react` be a peer dependency? Yes. * **Middle tier library** consuming `scheduler`. The `scheduler` library is consumed by `react`, but we don't know if it's safe to duplicate. Should it be a peer dependency? Maybe. Yes to be safe? * **Middle tier library** consumes `@emotion/react`, a css-in-js library. We aren't sure if the app host uses `@emotion/react`. We are also not sure if it's safe to duplicate. We think it is, but it could be bulky. And even if it's safe today, that might change in the future. So let's list it as a peerDependency. * **Middle tier library** consumes a `theme-provider` package. Should it be duped? No! There could definitely be problems if half the page used an older theme provider. Peer dependency it is. * **Middle tier library** consumes `react-transition-group`. Should it be duped? Well umm, no but it's probably safe. What could go wrong? And it's not likely to release frequently, so it's there's a low chance we will dupe... I think? I'm not sure. Let's list it as a `dependency`. The logic in each of these is guesswork because many times we don't really truely know if things are safe to duplicate or not. And truthfully they don't care... They don't care if the app host decides to duplicate `react`. They _just don't want to be forcing a decision, one way or the other._ And the answers will change over time. Something that's safe to duplicate today might get very bulky and be untenable to duplicate tomorrow. And to make it worse, the more things we add as peer dependencies, the more work it is for **app hosts** to manage them. Case in point: when you want to use Material UI, you must install it like this: ```bash npm install @mui/material @emotion/react @emotion/styled ``` See [MUI docs](https://mui.com/material-ui/getting-started/installation/#npm) for more examples. In other words, *app hosts* are now responsible for managing the `@emotion/react` and `@emotion/styled` dependencies, even if they don't directly use them. This is fundamentally counter-intuitive and prone to failure and misjudgement. E.g. if I just want to use MUI, and emotion decides to add a 3rd package dependency, I will now need to add that too. Or, maybe because I don't care about emotion directly, I'll choose to ignore the unmet peer dep. But the more glaring problem is that in each case above, the *wrong layer* is making these decisions. **Middle tier libraries** should not care about these problems. They should care about the semver range of their dependencies; that's it. The answer to whether a package should be duplicated or not should only be provided at the 2 other use cases: **app hosts** and **atomic libraries** - not at the **middle tier layers**! ## App hosts want things to work and they want minimum bundle sizes When middle tier libraries say `react` and `react-dom` are peers, their intent is to avoid being the source of a duplicate when semver ranges conflict. They're trying to communicate: "we think this would be a problem, but let the app host figure it out." App hosts know their app scenarios. Sometimes, 2 copies of react might be safe, but it really requires that the 2 copies are sandboxed and not trying to reconcile the same dom or share context. It is an edge case, not a common case. Many of us who have run into the "2 copies of react" problem and know all too well that this is very undesirable; not just at a functional level, but even regarding bundle size. 2+ copies of React will bloat your app fast. In other words: **app hosts should be able to declare dependencies as singletons, regardless of whether or not middle-teir libraries declared them peers, and the installation should fail when that is violated.** ## Atomic libraries know best when they aren't ok with duplication In a few of the earlier cases when we asked if things are OK to dedupe, the middle-teir libraries are guessing and making decisions on behalf of the atomic libraries. They don't know if `emotion` is safe to duplicate when semver ranges collide. Even if it were today, maybe tomorrow it isn't. That's probably why MUI lists it as a peer. The atomic library itself knows best what the right policy to apply. In most cases, duplication works. In some cases, it doesn't. Atomic libraries should have a say in what the default policy should be, in a way that app hosts can make a decision to override. ## A possible solution 1. Let app hosts define package policies. 2. Let atomic libraries define their default package policy. 3. Enforce that in the package managers. 4. Deprecate peer dependencies and keep things simple for middle layer libraries. App hosts might define policies like so: ```js { dependencyPolicies: [ // Declare react dependencies to be singletons: { match: ['react', 'react-dom'], policy: 'singleton' } // Allow for MUI to be duplicated: { match: ['@mui/*'], policy: 'duplicate' } ] } ``` The exact format is worth discussing options; there are probably more edge cases here to support, but the concept is that we can easily enforce which things are expected to be deduplicated. Using this definition, you could technically keep your entire graph de-duplicated using a wildcard policy: ```js { dependencyPolicies: [ { match: '*', policy: 'singleton' } ] } ``` But app host dependency graphs can be huge and it's difficult to know exactly what should or shouldn't be enforced as a singleton. That's where atomic and mid-tier library packages could define their own default policy: ```js { name: '@emotion/react', dependencyPolicy: 'singleton' } ``` ## What would errors look like? Let's take a common scenario: * React declares itself as having a default singleton policy * MUI declares React 17 as a dependency * App host declares React 18 and MUI as dependencies When you npm install, we have a conflict which introduces both React 17 and 18 in the graph. But because of the default singleton policy, you see this error message: ```bash ~/git/app > npm install Error: The install could not complete because of duplicated singelton dependencies existing in the graph. Singletons: react [ "17.0.0", "18.0.0" ] react-dom [ "17.0.0", "18.0.0" ] To resolve these, use `npm assist` to provide guidance for each conflict. ~git/app > npm assist react The dependency `react` is a singleton and has 2 versions in the dependency graph. Choose how to resolve this: > Choose a specific version to snap to Allow the duplication ``` If the user choose a specific version: ```bash > Choose a specific version to snap to: > 18.0.0 (Released N days ago (MM/DD/YYYY)) 17.0.0 (Released N days ago (MM/DD/YYYY)) ``` For 17: ``` Your package.json has been modified to use ^17.0.0 of react, which satisifies all semver requirements. Please re-install dependencies when you're ready. ``` For 18: ``` These packages do not list compatibility with react 18.0.0: * @mui/material (^17.0.0) - <git repo url> Please reach out to the package owner to include ^18.0.0 on their dependency support. ``` If the user chooses to allow duplication: ```bash > Allow the duplication Your package.json has been modified to add the singleton policy override for `react`. You can now install without this error. ``` ## How would this roll out? We can't simply ditch peerDependencies until this idea is solidified, agreed on, and have been proven to work. We will need to do the following: * Define the schema * Decorate a large graph with the appropriate schema entries * Build a tool which can detect the violations and break on postinstall * Build a tool representing the assist logic * Refine until we're happy The metadata could supplement peerDependency relationships within middle-tier libraries until it becomes adopted as a standard and enforced through package managers. Once the support is there and enforced, peerDependencies could be deprecated and essentially go away. Having policies defined at the app host and atomic components gives us the tools we need to control the behaviors we expect, even when we disagree. Great error messaging gives us the tools we need to resolve conflicts and know what the next step is to take care of. Solid enforcement prevents our expectations from being violated. All is well.