Why you shouldn't use npm workspaces
When setting up a project, npm
is usually the package manager most people use because it is the default. You can also use npm
to set up a monorepo by using npm
workspaces. However, I do not recommend using npm
workspaces for managing multiple packages in a monorepo. In this article, I will explain why you shouldn't use npm
workspaces and why you should use pnpm
workspaces instead.
How does npm workspaces work?
Let's first explore how npm
workspaces work.
You can create a npm workspace by specify a workspaces
field in your package.json
. This field should be an array of globs that point to the directories of your workspaces. For example, specifying packages/*
will include all the directories in the packages
directory as packages.
// package.json
{
"name": "root",
"workspaces": ["packages/*"]
}
You can then create individual packages in the workspace inside the packages directory. For example, to create a utils
, web
and docs
package. The utils
package is used in the web
and docs
package.
// packages/utils/package.json
{
"name": "utils",
"type": "module" // So we can use ES module syntax (ie. import / export)
}
// packages/web/package.json
{
"name": "web",
"type": "module"
}
// packages/docs/package.json
{
"name": "docs",
"type": "module",
"dependencies": {
"date-fns": "3.6.0"
}
}
Running npm install
in the root of your project will install the dependencies of all the packages in the workspace.
Since docs
has a dependency on date-fns
, npm
will install date-fns
in the root node_modules
folder. It will also include each package in the workspace in the node_modules
folder. This is how your node_modules
would look like:
node_modules/
- date-fns/
- docs/
- utils/
- web/
Notice that the web
package and docs
package does not have a dependency on the utils
package but it can import files from the utils
package. We will touch on this later.
For more information on npm
workspaces, you can refer to the official documentation.
What are the problems with npm / npm workspaces?
There are a few problems with npm
/ npm
workspaces that make it not ideal for managing multiple packages in a monorepo. These reasons are ordered by importance:
1. No encapsulation of dependencies
In npm
workspaces, npm
installs the dependencies of all workspaces in the root node_modules
folder. This means that any package can access the dependencies of any other package in the workspace.
For example, let's say we have two packages in a workspace, docs
and web
. If docs
has a dependency on date-fns
, npm
will install date-fns
in the root node_modules
folder. Then web
can access date-fns
even though it is not a direct dependency of web
or date-fns
.
// packages/docs/package.json
{
"name": "docs",
"type": "module",
"dependencies": {
"date-fns": "3.6.0"
}
}
// packages/web/index.js
import { format } from "date-fns"; // Imports date-fns from the root node_modules folder
export function formatDate(date) {
return format(date, "yyyy-MM-dd");
}
console.log(formatDate(new Date())); // 2024-07-09
This can make it difficult to reason about the dependencies of each package. It is easy for someone in the team to accidentally use a dependency that is not intended for that package. Not only that, it makes it easy to import other packages in the workspace as a dependency. For example, the utils
package can import the web
package, causing a circular dependency.
// packages/utils/index.js
import { formatDate } from "web";
console.log(formatDate(new Date())); // 2024-07-09
This alone is a good reason to migrate to pnpm
workspaces, but let's look at 2 more reasons why npm
is not ideal in general.
2. Direct use of transitive dependencies
In npm
, you can directly use the transitive dependencies of a package. A transitive dependency is a dependency of a dependency. This problem isn't specific to the workspaces feature, but happens because of npm
's flat dependency tree.
For example, if the docs
package has a dependency on formik
, you can use the dependencies of formik
directly in the docs
package. If we import formik:2.4.6
, we can use lodash
which is one of formik
's dependencies directly in our code.
// packages/docs/package.json
{
"name": "docs",
"version": "1.0.0",
"dependencies": {
"date-fns": "3.6.0",
"formik": "2.4.6" // One of the dependencies of formik is lodash
}
}
// packages/docs/index.js
import _ from "lodash";
console.log(_.upperCase("hello")); // HELLO
The reason why this works is because npm uses a flat dependency tree. This means that all dependencies (and their dependencies) are installed in the node_modules
folder. So all of formik
dependencies are installed in the node_modules
folder.
node_modules/
- date-fns/
- formik/
- lodash/
- ... # other dependencies of formik
This is a problem because now anyone can use lodash
even though it is not a direct dependency of the docs
package, and it should not be used directly. It's also not clear where lodash
is coming from, and if formik
decides to remove lodash
as a dependency in the future, the docs
package will break.
This problem becomes even worse because of the first problem mentioned above, and means that the web
package can also use lodash
directly.
So how can we solve these problems?
We can solve these problems by using pnpm
workspaces instead of npm
workspaces.
First, what is pnpm?
pnpm
is a fast, disk space efficient package manager that uses a content-addressable storage to store dependencies. This means that if you have multiple projects that use the same dependencies, pnpm
will only install the dependencies once. This can lead to faster installation times and less disk space being used.
pnpm
also has workspaces support, but they work differently than npm
workspaces
Let's look at how pnpm workspaces work
In order to create a pnpm
workspace, you will need a pnpm-workspace.yml
file in the root of your project. This file should contain the paths to your workspaces. For example, if you have a packages
directory that contains all your workspaces, you can specify the following in your pnpm-workspace.yaml
:
# pnpm-workspace.yaml
packages:
- packages/*
You can then create individual packages in the workspace inside the packages directory similar to the npm
example. If we want to have utils
, web
and docs
packages and docs
and web
packages depend on the utils
package, we can create the following packages:
// packages/utils/package.json
{
"name": "utils",
"type": "module"
}
// packages/web/package.json
{
"name": "web",
"type": "module",
"dependencies": {
"utils": "workspace:*"
}
}
// packages/docs/package.json
{
"name": "docs",
"type": "module",
"dependencies": {
"date-fns": "3.6.0",
"utils": "workspace:*"
}
}
Running pnpm install
in the root of your project will install the dependencies of all the workspaces. The difference from npm
workspaces is that pnpm
will install the dependencies of each package in its own node_modules
folder. This means that each package can only access its own dependencies and not the dependencies of other packages in the workspace.
You can notice here we are using utils: "workspace:*"
to specify that we want to use the utils
package in the workspace. This is how your node_modules
would look like:
node_modules/
- .pnpm # local pnpm store
- .modules.yaml
packages/
- docs/
- node_modules/
- date-fns
- utils
- utils/
- web/
- node_modules/
- utils
So how does pnpm solve the problems with npm & npm workspaces?
1. Encapsulation of dependencies
As mentioned above, each package can only access its own dependencies. For example, if we have two packages in a workspace, docs
and web
, and docs
has a dependency on date-fns
, pnpm
will install date-fns
in the docsnode_modules
folder. Then web
cannot access date-fns
since it is not a direct dependency of web
.
// packages/docs/package.json
{
"name": "docs",
"version": "1.0.0",
"dependencies": {
"date-fns": "3.6.0"
}
}
// packages/web/index.js
import { format } from "date-fns"; // Error: Cannot find module 'date-fns'
2. No direct use of transitive dependencies
In pnpm
, you cannot directly use the transitive dependencies of a package. This means that you can only use the dependencies that are specified in the package.json
file of the package.
For example, if the docs
package has a dependency on formik
, you cannot use the dependencies of formik
directly in the docs
package.
// packages/docs/package.json
{
"name": "docs",
"version": "1.0.0",
"dependencies": {
"date-fns": "3.6.0",
"formik": "2.4.6" // One of the dependencies of formik is lodash
}
}
// packages/docs/index.js
import _ from "lodash"; // Error: Cannot find module 'lodash'
The reason that lodash cannot be used directly in the docs
package is because pnpm
does not use a flat dependency tree. This means that the dependencies of formik
are not installed in the node_modules
folder of the docs
package (or the root node_modules
folder). Rather, they are installed in a global store. This is how your node_modules
would look like:
node_modules/
- .pnpm # project's pnpm store
- date-fns@3.6.0
- formik@2.4.6_react@18.3.1
- lodash@4.17.21
- ... # other dependencies of formik
docs/
- node_modules/
- formik/ # linked to .pnpm/formik@2.4.6_react@18.3.1
The root node_modules/.pnpm
will contain a hard link to the formik
package in the global store. And then, the node_modules
in the docs
package will contain a hard link to the formik
package in the root node_modules/.pnpm
folder.
Conclusion
In conclusion, pnpm
workspaces are a better choice than npm
workspaces for managing multiple packages in a monorepo. pnpm
workspaces provide encapsulation of dependencies and do not allow direct use of transitive dependencies.
Subscribe to my newsletter
Read articles from Edrick Leong directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Edrick Leong
Edrick Leong
Software Engineer in Perth