Developer Tools · Package Managers

Peer, Optional, and Override Constraints in Resolution

Three mechanisms that break the assumption that manifests fully determine the graph: peer requirements scoped to an ancestor, optional edges with three outcomes, and overrides that rewrite the input.

Every resolution algorithm described so far assumes a closed input: a set of manifests, and from them the complete constraint set. Real ecosystems contain three mechanisms that violate that assumption in different ways. A peer dependency constrains a node that is not in the declaring package’s subtree. An optional dependency makes the constraint set depend on the environment. An override replaces a constraint the resolver would otherwise have read. Each is a deliberate hole in the model, and each has consequences that reach the lockfile.

Constraints whose scope is an ancestor

An ordinary dependency says “install this package in my subtree”. A peer dependency says “whatever provides this package above me must satisfy this range”. The declaring package does not want its own copy; it wants to use the copy its consumer already chose, because the two must share state, a singleton, or a type identity. Plugin systems are the canonical case: a plugin registers against a host’s API, and a second copy of the host’s core module would give it a different registry to register into.

In a single-version installer, the peer declaration is a constraint like any other, and the resolver intersects it with the host’s own requirements. In a multi-version installer, the declaration fights the installer’s central capability, described in Diamond Dependencies and Duplicate Versions. Duplication is how such installers satisfy conflicting requirements, and duplication is precisely what a peer dependency forbids. The result is a constraint that cannot be satisfied by adding a node; the only options are to change the ancestor’s version or to fail.

npm’s current behaviour is to install peer dependencies automatically when they are not provided, and to report a conflict when the ranges of several peers do not intersect. Earlier behaviour left them to the consumer, which meant a missing peer surfaced as a runtime error rather than an install error. Both behaviours are defensible: automatic installation makes the common case work and hides the constraint, while manual provision makes the constraint visible at the cost of frequent breakage. The existence of a flag to restore the older, lenient behaviour is itself evidence that strict peer checking rejects trees that used to install.

The resolver-side difficulty is that the peer constraint’s target is chosen by an ancestor decision, possibly one made before the peer was even discovered. A conflict therefore requires revisiting a decision that the search had already committed to, which is exactly what backjumping is designed to do, but at a level that may be far above the node where the conflict appeared. Resolvers that treat peer constraints as ordinary dependencies get this wrong: they install a nested copy, satisfy the constraint locally, and break the singleton the peer declaration existed to protect.

Optional edges and the three-valued outcome

An optional dependency declares an edge that may be absent. The reasons are usually environmental: a package offers native bindings for several platforms and depends on the one matching the host, or it integrates with a library only when that library is present. The resolver’s answer must therefore distinguish three outcomes for an optional edge: selected and installed, deliberately skipped, or attempted and failed.

Skipped and failed differ in who decides. A platform mismatch is decidable from metadata — the operating system and CPU fields a package declares — so the resolver can exclude the edge before any work. A build failure is discovered only when the package compiles, which is after resolution and often after a large download. Treating a build failure as fatal makes an optional dependency effectively required; treating it as a skip means the application can start in a state where a feature is missing, and the failure moves to first use.

The resolution consequence is that a package’s presence no longer implies its dependents are present, and the graph is not closed under reachability from the root. An install-time filter removes a subtree, and any constraint that subtree imposed disappears with it. A resolver that computed the version of a shared dependency partly because of an optional package’s requirement has made a choice that the environment may invalidate.

This is why lockfiles in multi-platform ecosystems contain entries for packages the current machine will never install. The lockfile records the resolution for the union of platforms, because a lockfile that only described the producing machine would produce different versions on a different machine, and the point of the file is that it does not. The installer then applies the platform filter at install time, which is a second decision layered on the first. Verifying that such a lock satisfies its manifests has to treat an unsatisfied optional edge as acceptable, which weakens the check: the verification proves that every required edge is satisfied, not that the graph is exactly what the resolver would produce here.

Overrides rewrite the input

An override is a directive that changes what the resolver reads. It can force a transitive dependency to a specific version, replace one source with another, or redirect a name entirely. npm’s overrides field and Cargo’s [patch] table are two spellings of the same idea, with different scope rules.

[patch.crates-io]
serde = { path = "../serde" }

The Cargo form replaces the source for a package, keeping the name and version identity but taking the code from a path or a git repository. It applies to the whole workspace, and Cargo reports a patch entry that matched nothing, because an unused patch usually means the author expected a dependency that is not actually in the graph. npm’s overrides field is read only from the root manifest and applies to the whole install, with a nested key form that narrows a replacement to the dependents of one named package, which is how two different consumers of the same library can be given different forced versions.

The design consequence is that the resolved graph is no longer a function of the manifests alone. The override is part of the input, and it must be recorded, or a fresh install will produce a different result. It must also be checked against the constraint it replaces: forcing a version outside the range an intermediate package declared produces a graph that satisfies the override and violates the original requirement. Some tools permit this silently, which is the point of an override — it is an assertion by the user that they know better — but the violation should be visible rather than inferred from a mysterious runtime failure.

Overrides also interact badly with the property that made lockfiles reviewable. A patch that points at a local path makes the build depend on files outside the repository, and a lockfile that records the patch without the content gives a false sense of reproducibility. The path is a name for content the lockfile does not hash unless the tool hashes directory contents explicitly.

Configuration as part of the constraint set

The third hole is conditional compilation. A package can declare a dependency as optional and expose it through a feature, so that the dependency is present only when the feature is enabled. Which features are enabled is decided by the consumer, sometimes transitively, and the resulting graph differs per configuration.

Cargo resolves with feature unification: features requested by several packages in the build are enabled together for a given dependency, and the resolver produces a single graph for the resulting feature set, with newer resolver versions declining to unify across build dependencies and platform-specific targets where doing so would enable features the final artifact does not need. This means the graph depends on which packages are in the workspace and which features they request, not only on the versions. Enabling a feature can add dependencies, which can raise versions, which is why feature changes sometimes appear as lockfile changes even though no version constraint was edited.

The alternative — resolving separately per feature set — gives tighter graphs and more combinations to check. The trade is the same one that appears throughout resolution: one answer that is valid for everything versus many answers each valid for one configuration. A single answer is easier to lock and to cache; many answers are smaller and avoid pulling in code a build will not use.

Why these three belong together

Peer dependencies, optional edges, and overrides all weaken the claim that the lockfile is a complete description of the installation. A peer constraint is enforced at a scope the graph does not express as an edge. An optional edge makes the graph environment-dependent after resolution has finished. An override changes the input rather than the output, and feature selection changes it per configuration.

The practical response is to record more, not less: which overrides were in force, which optional entries were skipped, which features were enabled. The lockfile that records only the resulting version list is the one that produces surprises on a different machine or after a flag change. The design tension is that recording all of it makes the file larger and more volatile, and every volatile field is a merge conflict waiting to happen. That tension is the same one that Lockfile Determinism and Merges describes from the writer’s side, and it is the point at which the resolution model stops being a graph problem and becomes a description of a build environment.

The assumption to keep in view is that a package is a name at a version, chosen once. Peer dependencies say the choice belongs to someone else. Optional dependencies say the choice may not exist. Overrides say the choice was already made by a human. Each is a legitimate need that the base model cannot express, and each is implemented by adding a side channel to resolution rather than by extending the graph. Side channels accumulate, and the cost of each is paid by every tool that reads the result.

References

  1. npm package.json documentation: peerDependencies — npm project
  2. The Cargo Book: Overriding Dependencies — Rust project
  3. The Cargo Book: Features — Rust project