docs(di): a need is an obligation, not a lookup - #85
Conversation
The property #50 turns on, and nothing pinned it. "A need bubbles up" reads as "a provider sees whatever the tree happens to hold", and Needs subtracts Available — own provides plus imports' EXPORTS — so a module that imports the exporter and re-exports nothing leaves a sibling's need standing. Both arms: the opaque holder, and the one difference that discharges it, exports: [ConfigModule]. The flat runtime graph would have resolved it either way; the type channel is what refuses.
Decides #50: keep the model. The issue's own framing is what made it look like a hole — di already has NestJS's visibility rule for SATISFACTION (measured: a sibling's private observability() does not discharge a slice's Logger), and the only divergence is what happens to a need nothing local satisfies. NestJS errors where it is written; di carries a typed obligation to whoever composes the module, gated at Module.build / Module.scoped / start. So there is no @global to add: NestJS needs one because the error is local, and here the obligation already arrives at the composition root, which is the one place a cross-cutting module is imported. And substitution stays recomposition, which keeps tappedAmqp's seam open — import-visibility would have needed overrideProvider (#63) to stay testable, and #63 is now unblocked rather than forced. packages/di/CLAUDE.md carries the decision for a contributor; modules-and-privacy.md answers the reader's version of it, which is "where is @global".
There was a problem hiding this comment.
Pull request overview
This PR documents and pins the decision from #50 that di treats unmet needs as typed obligations carried to the composition root (rather than local module errors), while still keeping visibility constrained to a module’s own provides plus its imports’ exports.
Changes:
- Adds a new
.test-d.tscase asserting that only an import’s exports discharge downstream needs (opaque vs passthrough holder). - Updates
packages/di/CLAUDE.mdwith the clarified visibility model and the rationale (incl. NestJS comparison and why@Globalisn’t needed). - Updates the docs explanation page to reflect the same decision in reader-facing terms.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| packages/di/src/module.test-d.ts | Adds a type-level regression test pinning the “imports’ exports only” need-discharge rule. |
| packages/di/CLAUDE.md | Records the #50 decision and clarifies the model as “obligation, not lookup,” with consequences and rationale. |
| docs/explanation/modules-and-privacy.md | Adds a NestJS comparison section explaining why unmet needs travel to the root and why there’s no @Global. |
Suppressed comments (1)
docs/explanation/modules-and-privacy.md:68
- Same grammatical issue here: "a need nothing local satisfies" reads as missing "that".
`di` has the first half and not the second. What a module can see is still its
own provides plus its imports' exports — a sibling that imports
`observability()` and re-exports nothing does **not** satisfy your `Logger` — but
a need nothing local satisfies is not an error. It stays in the module's `Needs`
channel and travels to whoever composes the module, and the
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| // "a need bubbles up" reads as "a provider sees whatever the tree | ||
| // happens to hold", and that is not what this is: the flat runtime graph | ||
| // would resolve `AppConfig` here, and the type channel is what refuses | ||
| // to. |
| The one real divergence from NestJS is what happens to a need nothing local | ||
| satisfies. NestJS makes it an error where it is written; di makes it a **typed | ||
| obligation** carried in the third channel until an ancestor discharges it, gated |
| The same computation answers a question NestJS answers differently. There, a | ||
| provider sees only what its own module declares or imports, and a need nothing | ||
| local satisfies is an error where it is written — which is why NestJS also needs | ||
| `@Global`, a way for a cross-cutting module to be visible without being |
|
Correction — the central claim in this PR is wrong, and the issue is right. I measured the sibling case (a module that imports Type level — the root's own const Slice = Module("Slice")({ provides: [RepositoryProvider], exports: [OrderRepository] });
const Root = Module("Root")({ imports: [Slice], provides: [DatabaseProvider], exports: [OrderRepository] });
const rootNeedsNothing: Equal<Channels<typeof Root>[2], never> = true; // compilesRuntime — the slice's provider receives the root's instance: So a slice genuinely does see providers from the root. Draft until #50 is |
|
Superseded by the corrected decision — see the PR that replaces it. The premise in this PR is wrong: the ancestor case is what #50 is about, and a root does supply an imported module's need without that module mentioning it. |
Closes #50.
The decision
Keep the model. The issue's framing — "needs bubble up until some ancestor
discharges it" — is what made it look like a hole, and the measurement says the
opposite.
A module's
NeedsisExclude<its providers' deps | its imports' unmet needs, Available>, andAvailableis what the module provides plus what its imports export —nothing else. So the satisfaction rule is already NestJS's. Measured: a sibling
that imports
observability()and re-exports nothing does not discharge aslice's
Logger.The one real divergence is what happens to a need nothing local satisfies:
NestJS makes it an error where it is written, di makes it a typed obligation
carried in the third channel until an ancestor discharges it, gated at
Module.build/Module.scoped/start.AuditSlicedeclaring[Logger]and importing nothing is that channel working — nothing can run it until a root
supplies one, which
needs-gate.test-d.ts'sLoggerlessAmqpalready pins.The two open questions the issue attached
@Global: not needed, and that follows from the above. NestJS needs onebecause the error is local, so a cross-cutting provider must be visible
everywhere. Here the obligation travels to the composition root, which is the
one place
observability()is imported anyway.tappedAmqp's seam stays open. Import-visibility was the arm that neededoverrideProviderto remain testable — the issue measured both halves of why(
[di] two providers registered for port "LoggerConfig", then the closedrecording seam).
What is in the diff
module.test-d.ts— "only an import's own exports discharge a need", botharms: the opaque holder that leaves the need standing, and
exports: [ConfigModule], the one difference that discharges it. The propertythe decision rests on had nothing pinning it.
packages/di/CLAUDE.md— the decision, for a contributor, with therecomposability argument the issue asked for.
docs/explanation/modules-and-privacy.md— the reader's version of the samequestion, which is "where is
@Global". The page already described themechanism correctly; what it did not have was the NestJS comparison.
No behaviour change, no changeset:
*.test-d.tsis excluded from the build.Gate
All six green from the root:
format --check,lint,typecheck(31/31),knip,test(30/30),build.