Unifying dependency versions across npm workspaces

npm overrides helps enforce specific dependency versions across packages within a codebase.

Given workspaces a and b require different versions of lodash:

/npm-workspaces npm pkg get dependencies -ws
{
  "a": {
    "lodash": "^3.10.1"
  },
  "b": {
    "lodash": "^4.17.21"
  }
}

Setting overrides in the root package.json will ensure all workspaces use that same version:

{
  "name": "npm-workspaces",
  "workspaces": ["packages/a", "packages/b"],
  "dependencies": {
    "lodash": "^4.17.21"
  },
  "overrides": {
    "lodash": "$lodash"
  }
}

Running npm install will then attempt to replace every non-installed reference of lodash with the version in overrides. lodash is hoisted to the root node_modules and automatically symlinked to both workspaces:

/npm-workspaces ❯ npm ls
├─┬ a@1.0.0 -> ./packages/a
│ └── lodash@4.17.21 deduped invalid: "^3.10.1" from packages/a
├─┬ b@1.0.0 -> ./packages/b
│ └── lodash@4.17.21 deduped invalid: "^3.10.1" from packages/a
└── lodash@4.17.21 invalid: "^3.10.1" from packages/a overridden

Note: npm will flag packages that don’t satisfy the overrides dependency version as invalid, and so take care when replacing dependency versions.

npm overrides only replaces non-installed references

overrides in installed dependencies (including workspaces) will not be considered in dependency tree resolution.

Dependency resolution overrides RFC

If npm install already ran for workspace a, and so workspace a has it’s own reference to lodash@^3.10.1 in node_modules and package-lock.json, then overrides will not consider replacing this version:

/npm-workspaces npm i -w=a
/npm-workspaces npm i
/npm-workspaces npm ls
├─┬ a@1.0.0 -> ./packages/a
 └── lodash@3.10.1
├─┬ b@1.0.0 -> ./packages/b
 └── lodash@4.17.21 deduped
└── lodash@4.17.21 overridden

In this scenario, to enforce the overrides.lodash version on workspace a, then all installed references to lodash in package-lock.json and node_modules must be removed. Precisely removing references to an installed dependency is not trivial as package-lock.json does not update when dependencies are uninstalled. Consequently, nuclear solutions like deleting package-lock.json and node_modules, and then re-running npm install from the root may seem warranted; but removing package-lock.json carries the severe risk of introducing breaking changes and security vulnerabilities as different dependency versions are installed that still satisfy package.json:

/npm-workspaces rm -rf node_modules
/npm-workspaces rm package-lock.json
/npm-workspaces npm i
/npm-workspaces npm ls
├─┬ a@1.0.0 -> ./packages/a
 └── lodash@4.17.21 deduped invalid: "^3.10.1" from packages/a
├─┬ b@1.0.0 -> ./packages/b
 └── lodash@4.17.21 deduped invalid: "^3.10.1" from packages/a
└── lodash@4.17.21 invalid: "^3.10.1" from packages/a overridden

For stability and security, ideally dependencies are updated in place:

/npm-workspaces npm i lodash@^4.17.21 -w=a
/npm-workspaces npm ls
├── a@1.0.0 -> ./packages/a
├─┬ b@1.0.0 -> ./packages/b
 └── lodash@4.17.21 deduped
└── lodash@4.17.21 overridden

But this may not be feasible, especially with third party dependencies.