avatarJennifer Fu

Summary

The web content provides an in-depth explanation of managing dependencies in a package.json file for JavaScript projects using npm, focusing on the nuances of peerDependencies and their implications for library development and consumption.

Abstract

The article discusses the importance of correctly structuring dependencies in package.json for npm-managed JavaScript projects. It outlines the five types of dependencies: normal, dev, peer, optional, and bundled dependencies. The focus is on peerDependencies, which are crucial for libraries or plugins to specify the exact versions of packages they need without bundling them, thus avoiding duplication and ensuring compatibility with the host application. The article also addresses common issues with peerDependencies in npm versions 4 to 6 and how npm 7 handles them differently with the Arborist algorithm. It provides solutions for development scenarios, such as adding peerDependencies to devDependencies to resolve issues during library development, and discusses the use of external dependencies in bundlers like rollup and webpack, which are similar to peer dependencies but have normal versions specified and are excluded from the output bundle.

Opinions

  • The author suggests that managing dependencies, particularly peerDependencies, is challenging but essential for the integrity of JavaScript projects, especially with the rise of micro-frontends and monorepo approaches.
  • The article conveys that the automatic installation of peerDependencies by npm 7's Arborist algorithm is a significant improvement over previous versions, which required manual installation or workarounds.
  • The author emphasizes the importance of understanding the differences between various types of dependencies to avoid common pitfalls, such as bundling multiple versions of a package, which can lead to increased bundle size and potential errors.
  • It is the author's opinion that peerDependencies are a powerful tool for library developers to ensure compatibility without dictating the exact versions that the consuming application must use, thus allowing for more flexible project configurations.
  • The author implies that while external dependencies serve a similar purpose to peerDependencies, they are more suitable for library development as they define specific versions and are explicitly excluded from the final bundle.
credit: unsplash

An In-Depth Explanation of package.json’s Dependencies

Dependencies, devDependencies, and peerDependencies: What do they mean?

If you use npm (Node Package Manager) to manage a JavaScript project, it is vital to structure dependencies correctly in your package.json. Just one npm command, npm install, can install all dependent packages based on the specification.

With the emerging micro-frontends and monorepo approaches, the interrelationship between dependencies becomes ever more challenging.

This article describes different types of dependencies in package.json, and shows a few solutions we’ve adopted regarding peerDependencies issues during our product development. In the end, we talk about external dependencies.

Types of Dependencies

There are five types of dependencies defined in the npm documentation:

1. Normal dependencies

The normal dependencies: These dependent packages serve as libraries to make a project work.

The final product must be bundled with these packages, which might be frameworks (such as React, Angular, Vue, etc.), utilities (such as i18next, Lodash, axios, etc.), and subcomponents in your organization.

"dependencies": {    
  "axio": "0.19.0",
  "i18next": "~17.0.6",
  "react": "^16.12.0",
  "react-dom": "^16.12.0",
  "react-i18next": "~10.11.4",
  "react-router-dom": "~5.1.2"
}

2. devDependencies

These dependent packages serve as tools for development. The final product doesn’t need these packages, which might be:

"devDependencies": {    
  "@types/jest": "24.0.18",
  "@types/react": "16.8.23",
  "@typescript-eslint/eslint-plugin": "2.8.0",
  "@typescript-eslint/parser": "2.8.0",
  "eslint": "^5.16.0",
  "husky": "^3.0.0",
  "lerna": "^3.16.4",
  "prettier": "^1.18.2",
  "rollup": "^1.0.0",
  "typescript": "^3.6.4"
}

3. peerDependencies

These dependent packages are needed by a product, similar to the normal dependencies. However, this product is not a final product. Instead, it is a library or plugin to be consumed by a host application.

For this situation, it is recommended to put the dependencies in the peerDependencies and expect the host application to include it.

There are a few reasons for this approach:

  1. It would keep the intermediate product’s package size small without external packages.
  2. It would not unnecessarily bundle the intermediate product with the fixed version third-party dependencies.
  3. The consuming host application itself may require the same packages.
  4. The consuming host application may depend on a number of libraries or plugins requiring the same packages, with the same versions or compatible versions.
"peerDependencies": {    
  "jest": ">=24 <25"
}
# This way, npm will issue a warning when some peer dependencies are missing.
npm WARN ts-jest@24.2.0 requires a peer of jest@>=24 <25 but none is installed. You must install peer dependencies yourself.

From version 4, npm dropped support for installing peerDependencies automatically, due to the technical challenges of deduplication algorithm.

But from version 7, npm uses the Arborist algorithm to automatically install peerDependencies.

peerDependenciesMeta provides additional information on how peerDependencies are to be used. Specifically, it allows peerDependencies to be marked as optional.

"peerDependencies": {    
  "jest": ">=24 <25"
}
"peerDependenciesMeta": {
  "jest": {
    "optional": true
  }
}

When a user installs packages, npm will emit warnings if packages specified in peerDependencies are not already installed. Marking a peer dependency as optional ensures npm will not emit a warning if the jest package is not installed on the host.

4. optionalDependencies

These dependent packages are needed by a product, similar to the normal dependencies.

However, npm will proceed if it cannot find them or fails to install. The product should have the capability to handle these type of issues. This is a rarely-used type.

5. bundledDependencies

These dependent packages are bundled when publishing the package. This preserves npm packages locally or has them available through a single file download.

The bundledDependencies are listed in an array, without versions. This is a rarely-used type.

Issues and Solutions With peerDependencies

Among all types of dependencies in package.json, dependencies and devDependencies are the most frequently used, and the concepts are straightforward.

Meanwhile, optionalDependencies and bundledDependencies are rarely used. The interesting and troublesome one is peerDependencies.

As we have mentioned earlier, starting from version 7, npm uses the Arborist algorithm to automatically install peerDependencies. Therefore, the following issues and solutions are only applicable to npm versions 4 - 6, or npm 7 that uses the --legacy-peer-deps flag at install time.

As we’ve mentioned earlier, peerDependencies provides the specifications for what the host application should provide. It works well for the production.

However, during the library or plugin development, npm install will not install packages specified in peerDependencies. If the peerDependencies are set as follows:

"peerDependencies": {    
  "react": "^16.12.0",
  "react-dom": "^16.12.0"
}

Then, ESLint for the dev build will likely report a warning:

Unable to resolve path to module ‘react’ import/no-unresolved 

In addition, the unit test cases will fail with the error:

Cannot find module: ‘react’. Make sure this package is installed.

The solution is to add peerDependencies into devDependencies.

"devDependencies": {    
  "react": "^16.12.0",
  "react-dom": "^16.12.0"
}

Then the dev build will also have React installed under node_modules. This solves our problem for library or plugin development.

In our environment, we build the host application along with the plugins.

Lerna is a monorepo tool that optimizes the workflow around managing multi-package repositories with Git and npm. In the dev mode, lerna bootstrap creates a symlink to the plugin’s installed devDependencies.

We actually end up with the following tree structure for react and react-dom from host-app’s dependencies and plugin’s devDependencies.

host-app
  └─┬ node_modules
    ├── react
    ├── react-dom
    └── plugin
          └─┬node_modules
            ├── react
            └── react-dom

This issue can also be caused by npm link, which uses symlink between host-app and plugin.

# link the plugin
$ cd host-app
$ npm link path/to/plugin

Npm’s dedupe functionality ignores the symlinks.

Usually, it still works with multiple copies of packages. However, React breaks when it is included twice (even if the version is equal). You may face this flashing red error:

Dan Abramov mentioned this issue:

“This has actually always been the case (React apps are subtly broken when there are two copies of React module). Hooks surface this immediately which I guess is good.”

For the npm link issue, it would work if linking React itself back to the host application:

# link the plugin
$ cd host-app
$ npm link path/to/plugin
# link its copy of React back to the host-app's React
$ cd path/to/plugin
$ npm link path/to/host-app/node_modules/react

We have adopted the more generic solution for a React project bundled with webpack:

External Dependencies

Aren’t dependencies complicated enough?

There are external dependencies introduced by bundlers, such as rollup and webpack. External dependencies are most useful for building libraries. It provides a way of excluding dependencies from the output bundles. Instead, the created bundle relies on that dependency to be present in a final product. Does this sound similar to peer dependencies?

Typically, external dependencies have normal versions specified. Peer dependencies specify ranges.

To configure external dependencies, you specify packages as normal dependencies, and you set them as external in a config file.

external: ['packageName1', 'packageName2', 'packageName3']

Then they are not bundled by the bundler.

For peer dependencies, npm install will throw an error if they do not exist in the final product. However, for external dependencies, the bundler will install them if they do not exist in the final product.

What if the external dependency and the final product’s dependency specify different versions? It provides the possibility that the library uses one version defined in external dependency, and the final produce uses its own version. However, bundling multiple versions of a package is discouraged. It inflates the bundle size and introduces errors (such as multiple React error).

To make things more complicated, there are rollup and webpack plugins to automatically make all dependencies to be external.

It sounds like a lot of fun to explore!

Thanks for reading. I hope this was helpful. You can see my other Medium publications here.

Package
Webpack
Rollup
External Dependencies
Peer Dependencies
Recommended from ReadMedium