Skip to content

Add modular multibindings: bind() + compose()#20

Open
kburov-sc wants to merge 3 commits into
mainfrom
kburov/multibindings
Open

Add modular multibindings: bind() + compose()#20
kburov-sc wants to merge 3 commits into
mainfrom
kburov/multibindings

Conversation

@kburov-sc

@kburov-sc kburov-sc commented May 15, 2026

Copy link
Copy Markdown
Collaborator

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

  • Reify each contribution as a portable, type-branded value modules can export.
  • The composer validates a binding's deps against the core at compile time and names the missing key rather than emitting a generic type mismatch.
  • Private helpers attach to a binding without widening the composed container's type.

Test plan

  • Full suite, lint, and tsc build are clean; 100% coverage held.

🤖 Generated with Claude Code

Base automatically changed from kburov/provides-chain-perf to main May 19, 2026 14:36
kburov-sc and others added 2 commits June 18, 2026 13:28
…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>
@kburov-sc kburov-sc force-pushed the kburov/multibindings branch from 8c52927 to d057cf4 Compare June 18, 2026 03:29
@kburov-sc kburov-sc requested a review from msilivonik-sc June 18, 2026 03:52
Drives the styleguide CI step to green; no functional changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant