Software Engineering · Dependencies
Peer Requirements and the Problem of Straddling Two Majors
Why some dependencies must be singletons supplied by the consumer, what happens when a package is asked to support two incompatible majors at once, and how the constraint is expressed.
Most dependency edges are private. A package needs a library, it declares the library, and the installer supplies it. The package’s internals are nobody else’s concern, and two copies of the library in one process are harmless as long as the package does not care about identity.
A plugin cannot work that way. A plugin implements an interface defined by a host, and the host passes objects to the plugin and receives objects back. Both sides must agree on the type of those objects, and in most languages agreement means the same module instance. If the host loads one copy of the interface and the plugin was built against another, the values do not match and the plugin fails in a way that looks like a version mismatch but is actually a duplicate-instance problem.
The mechanism that expresses this is the peer requirement: a declaration saying “this package does not supply the named dependency, it requires that the consumer supply a compatible one”. Getting the semantics right is more delicate than it appears, particularly when the publisher wants to support two major versions of the host at once.
What a peer requirement asserts
A normal dependency is a supply relation. The installer must place the named package somewhere reachable from the dependent, and it is free to choose the version within the declared range.
A peer requirement is a constraint on the consumer’s graph. It says: the package works only if a version of the host satisfying this range is already present at the position where both the plugin and the host will resolve it. It does not cause installation. If the host is absent, the plugin is useless, and older package managers treated that as a warning rather than an error; newer ones install the peer automatically when the consumer has not declared it, which is a behaviour change that moved the decision from the consumer to the installer.
The distinction matters because a peer requirement’s satisfaction is a property of a position in the tree, not of the graph as a whole. A host present at the top level satisfies a plugin hoisted beside it. A host present only inside an unrelated subtree does not, because the plugin’s upward search never reaches it.
Peer requirements also make the singleton explicit. If the host holds state — a registry of plugins, a renderer’s internal tree, a connection pool — two copies produce two states, and a plugin registered with one is invisible to the other. The peer declaration is the publisher’s way of saying “one copy, please, and here is the range I was tested against”.
The straddle: one plugin, two host majors
A library that hosts plugins faces a recurring choice. It has a stable major with many users and a new major in progress. Its plugin API changed in the new major. The plugin author wants one release that works with both, because maintaining two release lines doubles the work and splits the user base.
The naive expression of that wish is a range spanning both majors: >=2.0.0 <4.0.0 for a host whose majors are 2 and 3. The range parses, resolves, and is usually wrong.
It is wrong because the range language cannot express “the API is compatible within each major but different between them”. If the plugin calls a function that exists in 2.x with one signature and in 3.x with another, then a single code path cannot satisfy both. The range admits versions the code does not handle, and the failure appears at run time in whichever major the consumer happens to have.
The honest options are three:
Support both majors with a compatibility layer, and declare the wide range. The plugin detects the host’s version at load time and dispatches. This works and costs a branch and the maintenance of two code paths. The declaration is then truthful: every version in the range is handled.
Publish two releases, one per major, and let the consumer’s range select. plugin@2 for host 2, plugin@3 for host 3. The peer range in each release is narrow, and the consumer’s host version determines which plugin version is installed. This is the conventional answer and the one that keeps the code honest. It requires the plugin’s own major to align with the host’s, which is a coordination cost the ecosystem imposes.
Depend on the host directly instead of as a peer, and accept that the plugin brings its own copy. This defeats the singleton purpose: the plugin now talks to its own host instance, and the consumer’s host never sees the plugin. Occasionally that is what a package wants — a plugin that runs a private instance of something — but it is not a plugin architecture.
The straddle attempt is common because the first option looks cheaper than the second, and because a wide range appears to solve the consumer’s problem without requiring them to change anything. In practice the consumer’s resolver may then pick either major, and the plugin’s behaviour depends on an accident of the graph.
How installers interpret an unsatisfied peer
Behaviour here diverged historically and still differs between tools, which makes peer requirements a portability hazard.
The strict reading treats an unsatisfied peer as a resolution failure. The consumer must declare a compatible host, or installation stops. This produces loud failures and correct graphs.
The lenient reading treats it as a warning. Installation completes, the plugin loads, and the failure appears later as a missing host module or a type error. This produces quiet breakage, and it is the reading that made peer requirements unpopular in their early years.
The automatic reading installs the peer at a version the installer chooses when the consumer has not declared it. Convenient, and it can silently introduce a host version the consumer never intended, including a major the consumer’s other packages do not support.
Whichever reading a tool uses, the peer requirement is not enforceable at run time. Nothing in the plugin’s loaded code checks that the host instance it received came from a version within the declared range. The declaration is a constraint on resolution; if resolution is bypassed — by a vendored directory, a manually edited tree, or a bundler that inlines a different copy — the constraint does not hold.
Version alignment across a plugin ecosystem
When a host and its plugins are maintained by different parties, the peer range is the contract, and its width determines how much coordination is required.
A narrow peer range — exactly one major, or one minor line — means every host release requires plugin releases. The consumer upgrading the host must upgrade all plugins in lockstep, and any plugin that lags blocks the upgrade. This is the safe configuration and the operationally painful one.
A wide peer range shifts the burden to the plugin: it must handle every host version in the range, which for a multi-major range means runtime dispatch and testing against all of them. The consumer upgrades freely and the plugin carries the compatibility work.
An intermediate pattern uses a range that spans minor versions of one major, with the plugin declaring that it supports the current major and the previous one only during a transition window. This is the same straddle with a deadline, and it works if the deadline is honoured.
The failure to watch for in any wide peer range is that the plugin’s testing covers only the version the maintainer uses. A range is a claim about all versions it admits, and a claim that is untested for most of its span is a claim that will eventually be false.
Where the constraint belongs in the manifest
Peer declarations interact with the rest of the manifest in ways that produce subtle errors.
A package can declare the same name as both a dependency and a peer. The semantics of the combination differ by tool and are a frequent source of confusion: some resolve the peer constraint first and then satisfy the dependency from the same node, others create two nodes. The safe practice is not to declare both unless the tool’s documentation states the combined meaning.
The declaration itself is a range, evaluated against the consumer’s graph rather than used to fetch anything:
{
"name": "plugin-for-host",
"version": "3.1.0",
"peerDependencies": {
"host": "^3.0.0"
},
"peerDependenciesMeta": {
"host": { "optional": true }
}
}
The range ^3.0.0 is the whole compatibility claim: any host 3.x is asserted to work. The optional flag changes the resolver’s behaviour from “fail if absent” to “proceed if absent”; it does not change the plugin’s obligation to handle the absence.
An optional peer carries a different risk from a required one. The plugin must handle the host’s absence, and that branch is the one least likely to be exercised, so a plugin that is correct with the host present may fail at load when it is missing.
Peer constraints can be narrowed by the consumer in some tools, which is how a project pins a plugin to a host version it has actually tested. That is the right place for the decision: the consumer knows its own host version, and the publisher cannot.
The broader principle is that a peer requirement is an interface contract expressed in the version language, and like any contract it is only as strong as the publisher’s testing. The range algebra determines what the text admits; nothing determines whether the code inside actually works across that set. Where the contract is critical — a plugin API that many parties implement — pinning both sides and reviewing the pin is the only mechanism that makes the claim checkable, and it is the same reasoning that governs how much duplication a graph can tolerate.
The singleton problem is not solved by versions
A final point that version constraints obscure: making the host a peer ensures one copy exists in the tree, and it does not ensure one copy is loaded. In a language where a module can be reached by two different paths — a bundled copy and a source copy, a symlinked workspace and its real path — resolution by path produces two instances even though the tree contains one directory.
Symlinks are a frequent culprit. A workspace layout that links a local package into node_modules gives the loader a path that may be resolved to its real location or preserved as a link, depending on configuration, and the two choices produce different module identities for the same file. The symptom is a peer requirement that is satisfied in the lockfile and violated at run time.
The fix is configuration, not versions: ensure the loader resolves consistently, and keep a single mechanism for reaching each package. Version declarations describe the graph; they cannot describe how the loader walks it.
References
- npm docs: peerDependencies — npm documentation
- Semantic Versioning 2.0.0 — semver.org
- Cargo Reference: Specifying dependencies — The Cargo Book