"Catalogs" allow multiple package.json
files to share the same version specifier of a dependency through a new catalog:
protocol.
A catalog may be defined in pnpm-workspace.yaml
:
packages:
- packages/*
catalog:
react: ^18.2.0
react-dom: ^18.2.0
redux: ^4.2.0
react-redux: ^8.0.0
A package referencing the catalog above will have the following on-disk and in-memory representations.
On-Disk
{
"name": "@example/react-components",
"dependencies": {
"react": "catalog:",
"redux": "catalog:"
}
}
In-Memory and Publish Time
{
"name": "@example/react-components",
"dependencies": {
"react": "^18.2.0",
"redux": "^4.2.0",
}
}
A common workflow in monorepos is the need to synchronize on the same version of a dependency.
For example, the foo
and bar
packages of a monorepo may declare the same version of react
in their package.json
files.
{
"name": "@monorepo/foo",
"dependencies": {
"react": "^18.2.0",
}
}
{
"name": "@monorepo/bar",
"dependencies": {
"react": "^18.2.0",
}
}
For smaller monorepos with a few packages, it's easy to manually ensure these versions stay in sync. For monorepos with hundreds of packages and many contributors, it becomes untenable to rely on code review to ensure every dependency has a single version for all packages.
As a result, multiple versions of the same dependency appear over time. This can cause different flavors of surprising problems.
- In projects that bundle dependencies, multiple versions inflate the size of the final result deployed to users.
- Differing versions result in multiple copies that may not interact well at runtime, especially if features like
Symbol()
are used. For example, React hooks will error if a component is rendered with a different copy of React in the same app. - For TypeScript, multiple copies of the same
@types
package causes compile errors from mismatching definitions. The compiler diagnostic for this is usually: "argument of typeFoo
is not assignable to parameter of typeFoo
". For developers that have seen this before, they may realize this diagnostic is due to a dependency version mismatch. For developers new to TypeScript, "Foo
is not assignable toFoo
" is very confusing.
While there are situations differing versions are intentional, this is more often accidental. Multiple differing versions arise from not reviewing pnpm-lock.yaml
file changes or not searching for existing dependency specifiers before adding a new one. The later is typically unwritten convention in most monorepos.
In addition to reducing the likelihood of multiple versions of the same dependency in a monorepo, the new catalog:
protocol reduces merge conflicts. Any package.json
files using the catalog protocol to declare a dependency do not need to be edited when changing the version of that dependency. This side steps package.json
merge conflicts by avoiding edits to them in the first place.
Merge conflict resistance is a primary motivator for introducing catalogs as a first-class feature to pnpm. This is only possible through a new specifier protocol. See What kinds of merge conflicts are avoided? for details.
Catalogs are configured in pnpm-workspace.yaml
and available to all workspace packages.
A default or unnamed catalog can be specified using the catalog
config.
packages:
- packages/*
# These dependencies can be referenced through "catalog:default" or "catalog:"
catalog:
jest: ^29.6.1
redux: ^4.2.0
react-redux: ^8.0.0
Additionally, named catalogs can be created by adding a catalogs
config. Any named catalog will be available for reference through the catalog:<name>
version specifier protocol.
packages:
- packages/*
# Can be referenced through "catalog:default" or "catalog:"
catalog:
jest: ^29.6.1
redux: ^4.2.0
react-redux: ^8.0.0
catalogs:
# Can be referenced through "catalog:react17"
react17:
react: ^17.0.2
react-dom: ^17.0.2
# Can be referenced through "catalog:react18"
react18:
react: ^18.2.0
react-dom: ^18.2.0
The default catalog specified directly under catalog
has special treatment; package authors can specify catalog:
if they prefer conciseness, or catalog:default
for explicitness. Attempting to create a named catalog of default
under catalogs
will throw an error.
Suppose a git commit upgrades the version of a commonly used dependency for all workspace packages. Suppose another git commit at the same time is attempting to perform any of the following:
- Change the version of a dependency line-adjacent to the upgraded dependency.
- Add or remove a dependency line-adjacent to the upgraded dependency.
- Add a new declaration of the upgraded dependency.
Scenarios 1 and 2 will result in a git merge conflict that prevents these two commits from merging. This will not be the case when using the catalog:
protocol.
Scenario 3 will result in an inconsistent/lockfile, which is not prevented by this RFC.
While there's existing tooling in the frontend ecosystem for reusing versions (syncpack
being one great option), builtin support from pnpm allows several improvements not otherwise possible.
As mentioned in Motivations — Merge Conflict Resistance, the new catalog:
protocol enables package.json
files to remain unchanged when upgrading or downgrading a dependency. Existing approaches typically synchronize package.json
dependencies by editing them in bulk.
For example, developers may find + replace "react": "^18.1.0"
to "react": "^18.2.0"
across a repository.
- These giant edits lead to churn in
package.json
files and merge conflicts with other commits editingpackage.json
files. - Repository maintainers have to remember to run this synchronization step periodically, or create a Continuous Integration step to verify the repo is in a good state.
Neither of these error-prone operations are necessary when using a catalog.
There might be a tight relationship between foo
and bar
.
{
"name": "@monorepo/foo",
"dependencies": {
"react": "^17.0.2"
}
}
{
"name": "@monorepo/bar",
"dependencies": {
"react": "^17.0.2"
}
}
A developer working primarily in @monorepo/bar
may not realize the implied coupling and upgrade @monorepo/bar
to react@18
without realizing an edit to @monorepo/foo
was also required.
The catalog:
protocol makes it more clear from just reading package.json
when a dependency is intended to be consistent across the monorepo. Ideally this person would search "catalog package.json" online and find pnpm.io docs.
Catalogs will be saved to pnpm-lock.yaml
under a new catalogs
key. This allows users to more easily review changes to catalogs and pnpm to perform faster up-to-date checks.
lockfileVersion: next
importers:
packages/foo:
dependencies:
react:
specifier: 'catalog:'
version: ^18.2.0
catalogs:
default:
react:
specifier: ^18.2.0
react-dom:
specifier: ^18.2.0
redux:
specifier: ^4.2.0
react-redux:
specifier: ^8.0.0
packages:
# ...
Similar to the workspace:
protocol, pnpm publish
will need to replace instances of catalog:
with valid specifiers before publishing.
The pnpm add
command will add versions from default catalog if it's configured. The pnpm update
command will prompt users if they wish to update specifiers in a catalog.
Although package.json
files do not need to be updated when a catalog version changes, the pnpm-lock.yaml
file will continue to require updates to affected importers
entries.
lockfileVersion: next
importers:
packages/foo:
dependencies:
react:
specifier: 'catalog:default'
version: 18.2.0 # ← If a user changes the catalog entry for react, this field needs to change.
catalogs:
default:
react:
specifier: ^18.2.0
packages:
This means it's still possible for the pnpm-lock.yaml
file to end up in an inconsistent/broken state after two git commits merge. This is technically a "merge conflict", but not one in the scope of git to detect.
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
lockfileVersion: next
importers:
packages/foo:
dependencies:
react:
specifier: 'catalog:default'
version: 18.2.0
+ packages/bar:
+ dependencies:
+ react:
+ specifier: 'catalog:default'
+ version: 17.0.1 # ← This field is incorrect
+
catalogs:
default:
react:
specifier: ^18.2.0
packages:
The RFC in its current state describes how catalog:
significantly reduces merge conflicts to package.json
files. There are ways to reduce merge conflicts and churn in pnpm-lock.yaml
, but not in a known way that's simple.
One option that may be explored is further normalization by the recording resolved concrete versions to the catalogs
portion of the lockfile.
lockfileVersion: next
importers:
packages/foo:
dependencies:
react:
specifier: 'catalog:default'
version: 'catalog:default'
packages/bar:
dependencies:
react:
specifier: 'catalog:default'
version: 'catalog:default'
catalogs:
default:
react:
specifier: ^18.2.0
version: 18.2.0
packages:
The problem with the format above is that it does not represent peer dependencies well:
lockfileVersion: next
importers:
packages/foo:
dependencies:
chai:
specifier: 'catalog:default'
version: 'catalog:default'
chai-as-promised:
specifier: 'catalog:default'
# Replacing ↓ with catalog:default would remove valuable information.
version: 7.1.1([email protected])
catalogs:
default:
chai:
specifier: ^4.3.8
version: 4.3.8
chai-as-promised:
specifier: ^7.1.1
version: 7.1.1
packages:
/[email protected]([email protected]):
# ...
/[email protected]:
# ...
Catalog versions alone are insufficient to completely prevent multiple versions of the same dependency. Dependencies of dependencies (transitive dependencies) may pull in a package that already exists elsewhere in the dependency graph, but on a different version.
For example, the foo
, bar
, and baz
workspace packages may all be using a catalog version for react
on ^18.2.0
. However, baz
may depend on quz-react-components
, which brings in an older version of React.
flowchart LR
classDef project color:#333333,fill:#bdd5fc,stroke:#333,stroke-width:1px
classDef package color:#333333,fill:#e5fce7,stroke:#05a000,stroke-width:1px
classDef node_modules color:#333333,fill:#f5f5f5,stroke:#999,stroke-width:1px
foo:::project
bar:::project
baz:::project
node_modules:::node_modules
foo --> react
bar --> react
baz --> react
baz --> quz-react-components --> react16
subgraph node_modules
react["[email protected]"]:::package
quz-react-components["[email protected]"]:::package
react16["[email protected]"]:::package
end
This scenario currently requires human intervention and is outside of the scope of this RFC. It's possible a future RFC proposal would address this. Such a new proposal would need to propose a configuration syntax for enforcing a single version throughout the full dependency graph.
Syncpack is a great open source tool for keeping package.json
dependency specifiers in sync on disk.
The proposed solution allows metadata to be defined in a singular file without copying definitions to other files on disk. This is a capability only possible by the package manager reading package.json
files.
An alternative mechanism for the version catalog is the pnpm.overrides
feature. While mechanically this allows you to set the version of a dependency across all workspace packages, it can be a bit unexpected if pnpm.overrides
rewrites a dependency's dependency to an incompatible version silently.
pnpm.overrides
is ultimately intended for a different purpose. The NPM RFC for a similar feature explicitly states that it should be used as a short-term hack to fix vendor problems.
Using this feature should be considered a hack in most cases, something that is done temporarily while waiting for a bug to be fixed, or to avoid excessive duplication caused by an overly strict meta-dependency specifier. https://github.com/npm/rfcs/blob/main/accepted/0036-overrides.md
The catalog:
protocol is conversely intended for long-lived usage.
- The Gradle build tool has a similar concept called version catalogs. This RFC's name was directly chosen as inspiration.
- Syncpack as mentioned above in the alternatives section.
- Rush has a "Preferred versions" concept that works similarly.
- Rust allows users to define a
[workspace.dependencies]
"table". Members of the workspace can then inherit its entries.