Add modular multibindings: bind() + compose()#20
Open
kburov-sc wants to merge 3 commits into
Open
Conversation
…butions
`appendValue` / `appendClass` only work when one place owns the Container
chain. They don't scale when independent modules want to contribute to a
shared registry (plugins, middlewares, extension points) without coupling
through a single chain. Multibindings reify a contribution as a value —
modules export `Multibinding<S, D>`s, the wire-up site composes them.
API surface:
- `bind<S>()` returns a `MultibindingBuilder` against a registry shape `S`
(typically `typeof someContainer`). Methods:
- `contributeValue(token, value)` — eager literal
- `contributeClass(token, MyClass)` — InjectableClass with static deps
- `contribute(Injectable(...))` — pre-built InjectableFunction
- `withInternal(PartialContainer)` — private helpers not exposed on
the composed container's type
- `compose(core, ...bindings)` applies the bindings in order. A binding's
phantom `D` carries the union of its contributions' deps (minus what
`withInternal` provides); `compose` rejects calls where the core doesn't
satisfy `D`, surfacing the missing keys as a `missingDeps` field on the
offending binding rather than a generic type mismatch.
Implementation only uses public Container/PartialContainer APIs, so all
the perf-branch wins (O(1) chain extensions, prototype-rooted factories,
chainedForEach traversal, lazy flat-factories snapshot) flow through
automatically.
README gets a "Modular Multibindings" section: motivation, four-file
plugin example (registry / auth / metrics / wire-up), `withInternal`
privacy semantics with a worked dep-flow rule, and a when-to-use-which
table. "Key Concepts" picks up a `Multibinding` entry.
Tests + verification:
- 14 new tests, 100% coverage on Multibinding.ts.
- 4 `@ts-expect-error` guards pin missing-core-dep, missing-internal-dep,
non-array-token, and element-type-mismatch.
- Full suite: 114/114 pass at 100% coverage. tsc -b on cjs/esm/types and
`npm run lint` are clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…al()
Swap the fluent builder for a smaller value-oriented API:
multibindings(registry) — factory that captures the registry shape.
Has `.contribute(token, value | class)` and
`.contribute(injectable)` overloads. Also
callable as `multibindings<S>()` when only
a type is available.
combine(...mbs) — bundle several Multibindings into one;
deps union, applied left-to-right.
withInternal(partial, ...mbs) — non-positional private helpers; partial's
services are subtracted from every binding's
deps regardless of argument order.
compose(core, ...mbs) — unchanged.
The `Multibinding<S, D>` brand and `compose`'s `missingDeps` error semantics
are preserved.
Why the swap:
- Single-contribution modules (the common case) collapse from
`bind<Registry>().contributeClass("plugins", X).build()` to
`m.contribute("plugins", X)` — one verb, no builder type to learn.
- `withInternal` becomes non-positional. Previously, dep subtraction only saw
contributions chained after the `.withInternal(...)` call, so swapping the
order silently produced a binding with wrong deps. The new shape
`withInternal(partial, mb1, mb2, ...)` subtracts over the union of every
binding inside the call.
- Class vs Injectable contribute through one overloaded method that dispatches
at runtime on arg shape, rather than separate `contributeClass` / `contribute`
methods.
- Builder removed entirely — `multibindings()` returns a plain object with one
method. No `MultibindingBuilder` class, no per-`.contributeX` allocation.
Why the factory rather than a free `contribute<S>(...)`:
TypeScript doesn't allow partial type-arg application — `contribute<Registry>(token, X)`
would require providing every type param positionally, and adding defaults masks
the inference of `Class` from the runtime arg (the default fires before
inference, widening `D` to `Record<string, unknown>`). Routing through
`multibindings(registry)` (or `multibindings<S>()`) captures `S` once and lets
the remaining type params infer normally from runtime args.
Tests + verification:
- 19 tests, 100% coverage on Multibinding.ts.
- Four `@ts-expect-error` guards (missing core dep, missing internal dep,
non-array token, element-type mismatch, plus a Multibinding<S,D> annotation
round-trip).
- Full suite: 119/119 pass at 100% coverage. `npm run lint` and `tsc -b` clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
8c52927 to
d057cf4
Compare
Drives the styleguide CI step to green; no functional changes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Background
Modules in a plugin/middleware/extension-point shape need to contribute to the same registry without sharing a Container chain. The existing per-chain append APIs require one owner; this adds a value-based contribution so modules stay independent.
Change
Test plan
🤖 Generated with Claude Code