To get the most out of this article you should have at least an introductory understanding of npm.

Overview

Here’s what we’ll cover:

  1. We’ll compare how Dependencies function versus Peer Dependencies.
  2. We’ll explore some examples that illustrate both types.
  3. We’ll then inspect how npm resolves version conflicts.
  4. Finally, once the fundamentals are clear, we’ll outline a strategy for determining when Peer Dependencies are the right choice.

The Context

To make this concrete, imagine you’re building an Angular Library or perhaps a simple JavaScript module that exposes a few functions.

Your project relies on packages from the npm Registry. These packages are your project’s dependencies.

Now suppose you want to turn your project into an npm package. You’d use npm pack to produce that package, and you might even publish it to the Registry.

Other teams will then include your package in their own projects. In package.json, you use Dependencies and Peer Dependencies to indicate which additional packages those projects need so that your package functions correctly.

At a high level, this is how the two mechanisms work:

Dependencies

Dependencies are declared in the dependencies object within package.json.

When you list a package in dependencies, you are effectively stating:

  • My code relies on this package to function.
  • If this package is missing from node_modules, install it automatically.
  • Also, install any packages that this package itself depends on—these are called transitive dependencies.

Peer Dependencies

Peer Dependencies are declared in the peerDependencies object within package.json.

When you list a package in peerDependencies, you are indicating:

  • My code works with this particular version of the package.
  • If the package is already present in node_modules, leave it alone.
  • If the package isn’t present, or it’s the wrong version, don’t install it—but do show a warning to the user.

Declaring Dependencies

You can add dependencies in the package.json file of your npm package. Let’s see how to declare both types, along with some typical examples.

Declaring a Dependency

A Dependency is an npm package that your package needs to run. Common examples include lodash, request, and moment.

You set up a regular dependency like this:

npm install lodash

npm then adds the package name and version to the dependencies object in your project’s package.json.

"dependencies": {
    "lodash": "^4.17.11"
  }

If you remember the older days, you might recall needing the --save flag to update dependencies in package.json. Fortunately, that’s no longer necessary.

Declaring a Peer Dependency

Peer Dependencies let you specify that your package is designed to work with a particular version of another package. Strong examples include Angular and React.

To add a Peer Dependency, you need to edit your package.json file by hand. For instance, for Angular component libraries, I suggest adding angular/core as a peer dependency. To declare that your package is built for Angular 7, you might write something like this:

"peerDependencies": {
  "@angular/core": "^7.0.0"
}

Understanding Conflicts

A frequent question I hear is whether a given npm package should be placed in dependencies or peerDependencies. The answer hinges on how npm manages version conflicts.

As I often do, I’ll walk you through a hands-on experiment so you can follow along. Let’s run a small test with npm.

The conflict-test Project

First, let’s set up a simple test project. I’ll call mine:
conflict-test

I created it using:

md conflict-test
cd conflict-test
npm init -y

Afterward, I edited the package.json file to add two dependencies:

"dependencies": {
    "todd-a": "^1.0.0",
    "todd-b": "^1.0.0"
  }

The todd-a and todd-b packages each bring their own dependencies:

todd-a

"dependencies": {
    "lodash": "^4.17.11",
    "todd-child": "^1.0.0"
  }

todd-b

"dependencies": {
    "lodash": "^4.17.11",
    "todd-child": "^2.0.0"
  }

Take note: both todd-a and todd-b rely on the same version of lodash. However, they conflict on todd-child:
todd-a requires todd-child version 1.0.0
todd-b requires todd-child version 2.0.0

Given this setup, I’m eager to see how npm handles it. In my main project, conflict-test, I execute npm install. As expected, npm installs todd-a and todd-b into node_modules, along with their transitive dependencies. After the install finishes, here’s what node_modules looks like:

node_modules
├── lodash 4.17.11
├── todd-a 1.0.0
├── todd-b 1.0.0
│   └── node_modules
│       └── todd-child 2.0.0
└── todd-child 1.0.0

Notice that our project has a single copy of lodash. But it contains two separate copies of todd-child. Specifically, todd-b gets its own private copy of todd-child 2.0.0.

The key takeaway is:

npm resolves version conflicts by adding duplicate private copies of the conflicting package.

A Strategy for Peer Dependencies

Our npm experiment shows that if you put a package in dependencies, there’s a risk it might be duplicated inside node_modules.

Having two versions of a package isn’t always a problem. But some packages create conflicts when multiple versions exist in the same project.

For example, imagine our component library was built with Angular 5. We certainly wouldn’t want our package to pull in a different, incompatible version of angular/core when someone adds it to an Angular 6 application.

The principle is simple:
We don’t want our library introducing another version of a package into node_modules if that could clash with an existing version and cause issues.

So, what should we choose: peerDependencies or dependencies?

Peer Dependencies or dependencies?

This leads us to the central question:

When my package needs another package, should I put it in dependencies or peerDependencies?

As with many technical choices, the answer is: it depends.

Peer Dependencies signal compatibility. For instance, you’ll want to be precise about which Angular version your library supports.

The Guidelines

Opt for Peer Dependencies when any of these conditions apply:

  • Having multiple copies of the package would cause conflicts
  • The dependency is exposed through your interface
  • You want the developer to choose which version to install

Consider angular/core again. If you’re building an Angular Library, angular/core is obviously a visible part of your library’s public interface. That makes it a natural fit for peerDependencies.

On the other hand, suppose your library uses Moment.js internally to handle some time-related inputs. Since Moment.js likely won’t appear in the interface of your Angular Services or Components, it belongs in dependencies.

Angular as a Dependency

If your documentation states that your library consists of Angular Components and Services, you might wonder:

“Do I even need to list angular/core as a dependency? Anyone using my library will already have an Angular project.”

That’s a fair point!

In most cases, we can assume that an Angular workspace already has Angular packages available. So strictly speaking, we might not need to list them as dependencies.

Still, it’s important to inform developers which Angular versions your library is compatible with. My recommendation is this:

Add at least angular/core for the supported Angular version to your peerDependencies.

This way, developers get a warning if they try to use your Angular 7 library in an Angular 6 project. There’s no need to list every Angular package; if they have angular/core, you can assume they have the rest.

Wrapping Up

If you’re uncertain, leaning toward peerDependencies is usually a safe bet. This approach gives package consumers the flexibility to decide which packages they want to include.