DEV Community

icy0307
icy0307

Posted on

Peer Dependencies in depth

TL;DR:

  1. The peerDependencies field is not for single copy of a package.
  2. Use peerDependencies when your host app needs a compatible version of another package to work with the library you provide. If your library doesn't have such constraints, list them as dependencies instead.
  3. The peerDependencies field is meant to warn you of any incompatibilities, so resolve unmet peer errors instead of ignoring them.
  4. As the library author, make both your dependencies and peerDependencies ranges as wide as possible.
  5. Do not overuse peerDependencies. Otherwise, library users will have difficulty resolving compatibility problems or may have to resort to ignoring them altogether.

Introduction

If you have worked with node packages, you might have come across the term "peer dependency."

However, I've noticed that some of my colleagues tend to think that peer dependencies are only for singletons or that **they guarantee a single copy* in final bundles* so the final code size could be smaller. However, this isn't quite accurate.

In this blog post, we will explore what peer dependencies are, how they differ from dependencies, and how package managers like 'npm' work with them. This post will also cover how to resolve peer dependencies conflict and best practice to de-duplicate package’s copy.

What are Peer Dependencies?

πŸ’‘ peerDependencies field is designed to be used for declaring compatibility requirements between a library and other libraries used by the host app.

In order to understand what peer dependencies are, we need to understand why they are needed in the first place and why dependencies are not enough.

So why dependencies are not good enough?

Dependencies work fine in terms of "compatibility". If a package you are building requires library foo version 1.0.0, and your app depends on foo@2.0.0, your code will work fine as there are two copy of foo.

β”œβ”€β”€ foo@2.0.0
└─┬ your-library@1.2.3
└── foo@1.0.0

However, certain packages depend on other packages that must be installed by the user in order to function properly. If a package requires the presence of other packages, it is likely designed to be used with specific versions of those packages. If the installed versions are not compatible, an error should be thrown to alert the user to choose a compatible version instead of silently installing multiple versions as a sub-folder (as the dependencies field does). This ensures that the library can work correctly with the host package.

For example, consider the following structure:

β”œβ”€β”€ react-dom@16.0.0
└─┬ your-library@1.2.3
  └── react@17.0.0
Enter fullscreen mode Exit fullscreen mode

Your library is a UI component library made with React. In order to work in a DOM environment, the react package needs to be rendered by react-dom, which should be installed in the root project using a method like root.render(<App />). Depending on your code, your UI component library may have some constraints on the version of react-dom, even though react-dom is not required in your library's source code. For instance, your library uses React hooks, so the react-dom library that mounts it must above version 16.8.0 .

If only using dependencies field, the react-dom would present inside your library folder. But this is futile because react-dom@17.0.0 is not the package that the host app uses to render your react component.

β”œβ”€β”€ react-dom@16.0.0 // this is the package the host app use to provide 'render' API
└─┬ your-library@1.2.3
  └── react@17.0.0
  └── react-dom@17.0.0 // this is futile, since this is not the react-dom actually being used.
Enter fullscreen mode Exit fullscreen mode

This is when react-dom should be a peer dependency.

Let’s look into another scenario, when react need to be a a peer dependency.

β”œβ”€β”€ react-dom@16.0.0
β”œβ”€β”€ react@16.0.0 
└─┬ your-library@1.2.3
  └── react@17.0.0 // incompatible with host app version
Enter fullscreen mode Exit fullscreen mode

The project install your library has already has react as dependency.react that your library uses needs to be the same version as the one installed by the user, so that hooks can work properly. In order to use your library, the host app’s react version must be updated. The user must be informed of this!!! Hence, the use of peer dependencies.

How Are Peer Dependencies Different From Dependencies?

As mentioned above, when deciding whether to list a package in dependencies or peerDependencies, the only thing that should be taken into consideration is whether it imposes compatibility constraints on other libraries that the host app is using or must install.

This means that the peerDependencies field is not for a singleton or single copy in the final bundle(We will explore how to achieve this later on).

Let's consider a question: should react and react-dom be listed as dependencies or peerDependencies?

The answer is: it depends.

Wait a second, did you just mention in the previous section that react and react-dom are peer dependencies? Yes, but that assumption was based on the idea that you are building a React-based UI library that provides React components for the host app to render. What if your library is a UI library that doesn't require react-dom to render and only adds rendered DOM to the outside DOM that the user provides? The use of React is relevant only to the library and does not impose any constraints on other packages installed by the user. Furthermore, the host app does not need to install any other packages for the library to work. Therefore, react and react-dom are dependencies that matter only to you: the library author. You may change your implementation to Vue or Svelte at any time.

Here is a little quiz:

  1. Should lodash be a peer dependency or dependency?
  2. Should tslib be a peer dependency or dependency?
  3. Should rollup be a peer dependency or dependency?

What about multiple copies of package?

Does this mean that if we use the dependencies field, we may end up with multiple copies of the same package installed?

Yes, if the versions are mismatched. This is also true for peerDependencies, except it won't resolve correctly by design.

As a matter of fact, react does support having multiple copies on the same page. This is important because having multiple copies ensures that all code works properly, even if the package version they rely on doesn't match. For example, if the host app is using a different version of lodash than the library is using, and there are breaking changes between these two versions, two copies are needed to ensure the code runs correctly.

Furthermore, bundlers like webpack have some bundle rules that cause multiple copies even though there is only one copy of a package in node_modules tree.

Image description

Library has no way to ensure there is only one copy of package in final bundles or that the object inside library is singleton by modifying package json. The only correct way to create a singleton object is by adding a flag in the global scope.The number of copies in the node_modules folder is not determined by dependencies or peerDependencies, but by the package versions of the packages.

So make sure your packages follow the semantic versioning rule for bothdependencies and peerDependencies , and make it as wide as possible like "^1.0"Β orΒ "1.x", instead of "~1.0.4" or "1.0.4" .For packages with a version below "1.0.0", the number in the middle represents the possibility of changes that break backward compatibility. If you know that such changes will not occur, you may use "<1.0.0" instead. Then the host app can use command like dedupe to deduplicate dependencies with overlapping ranges.

What should we do when conflicts occur with peer dependencies?

Should we ignore it?

If you have read the previous section, you now understand that peer dependency is a way to warn you that the packages you installed may not work with other packages you installed. Of course, you should resolve it instead of ignoring it.

Unfortunately, in npm versions 3 through 6, peerDependencies were completely ignored when building a package tree. Even today, both npm and pnpm have the strict-peer-deps flag set to false by default, which ignores indirect peers. This can cause packages to receive a peer dependency outside the range set in their package's peerDependencies object, potentially leading to bugs.

Using -force and -legacy-peer-deps is a shameful last resort, as it might break your code.

But how to resolve it exactly?

Before diving into how to resolve it, check whether the library really should list the conflicting package as a peer dependency causing so much trouble.

If not, ask the author to change it into the dependencies field, and ask the author to make the version of the package as broad as possible.

If it should be listed in peerDependencies:

  1. dependency is below what peerDependencies require:

Image description

Just update the required dependency.

  1. dependency is above what peerDependencies require:

Image description

If possible, ask the library author to update its peer dependencies. Alternatively, you can downgrade the dependencies of the host application. Asking author to update might be the only solution in some cases when versions really are incompatible and you cannot afford to downgrade.

Sometimes a library may be unmaintained, but still working. In such cases, you may know that broadening the version of peerDependencies would not cause any compatibility issues. Alternatively, you may realize that the peerDependencies it requires should not even be a peerDependencies in the first place, but making the author understand this may take forever. Fortunately, your package manager provides a convenient way to avoid such hassle.

For npm above 8, you can use overrides field in package.json

  "overrides": {
    "react": "$react"
  }
Enter fullscreen mode Exit fullscreen mode

For pnpm, there more choices:

  1. pnpm.peerDependencyRules.allowedVersions or some other related flag in package.json
{
  "pnpm": {
    "peerDependencyRules": {
      "allowedVersions": {
        "react": "17"
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode
  1. Pnpm installation hook: hooks.readPackage(pkg, context): pkg
const peerDependencies = ['peerDependency1', 'peerDependency2'];
const { overrides } = rootPkg.pnpm;
function overridesPeerDependencies(pkg) {
    if (pkg.peerDependencies) {
        for (const dep of peerDependencies) {
            if (dep in pkg.peerDependencies) {
                pkg.peerDependencies[dep] = overrides[dep];
            }
        }
    }
}

module.exports = {
    hooks: {
        readPackage(pkg, _context) {
            // skipDeps(pkg);
            overridesPeerDependencies(pkg);
            return pkg;
        },
    },
};
Enter fullscreen mode Exit fullscreen mode

However pnpm.overrides isn’t working now.

For yarn, sadly there is no way to do it for the time being, you can track this issue for future possibility.

Other Things You Should Know About Peer Dependencies

  1. β€œmissing peer” usually mean auto installation has been turned off, check out corresponding package manager doc for detail.
  2. peer dependencies are transitive.

    If packageX has a dependency on packageY, and packageY has a peer dependency on packageZ, then packageZ is also a peer dependency of packageX. This chain can continue until some package acts as the host.

    These can cause implicit problems, such as: having two copies of exact same version packages in pnpm monorepo, or a mismatch between babel plugins and babel version due to babel preset.

  3. peer dependencies can be optional. See this page for more details.

Conclusion

In conclusion, peer dependencies are an essential part of creating and maintaining a library to ensure compatibility with other packages used in a host application. They are not meant for singleton or single copy in the final bundle, and their use should be carefully considered to avoid unnecessary conflicts and inconvenience for users. For users, it is crucial to resolve conflicts when they occur rather than ignoring them. For library authors, it is recommended to use peer dependencies only when necessary and be lenient **in version requirement. This post has become much longer that I intended to, so we will talk about how to resolve multiple packages copies in future post.

Ref:

https://docs.npmjs.com/cli/v9/configuring-npm/package-json/#peerdependencies

https://docs.npmjs.com/about-semantic-versioning

https://nodejs.org/en/blog/npm/peer-dependencies

https://dev.to/arcanis/implicit-transitive-peer-dependencies-ed0

https://stackoverflow.com/questions/64573177/unable-to-resolve-dependency-tree-error-when-installing-npm-packages

https://github.com/pnpm/pnpm/issues/4214

https://github.com/yarnpkg/berry/issues/4099

https://dev.to/arcanis/implicit-transitive-peer-dependencies-ed0

https://github.com/npm/rfcs/blob/main/implemented/0025-install-peer-deps.md

Top comments (0)