Skip to content

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

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

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

Conversation

@kburov-sc
Copy link
Copy Markdown
Collaborator

Background

Stacked on #19. appendValue / appendClass only work when one place owns the Container chain. This adds a value-based contribution shape so independent modules can extend a shared registry (plugins, middlewares, extension points) without coupling through a single chain.

Change

  • bind<S>()MultibindingBuilder with contributeValue / contributeClass / contribute(Injectable(...)) / withInternal(PartialContainer) / build().
  • compose(core, ...bindings) validates deps at compile time; missing keys surface as a missingDeps field on the offending binding instead of a generic type mismatch.
  • withInternal lets a binding bring private helpers along; they're invisible to other bindings and don't widen the composed container's type.
  • README gets a "Modular Multibindings" section walking motivation, the four contribution forms, withInternal privacy, and a when-to-use-which table.

Test plan

  • npm test — 114 pass (14 new), 100% coverage held including new module
  • npm run lint + tsc -b (cjs/esm/types) clean

🤖 Generated with Claude Code

kburov-sc and others added 13 commits May 14, 2026 10:36
Long .provides()..provides() chains caused container assembly ANRs on low-end
devices because each call shallow-copied the entire factories map and the
constructor walked every factory to rebind thisArg. With N services that's
O(N²) work. This patch replaces the spread with prototype-chained extension
(Object.create) and switches memoize to call-site `this`, eliminating the
per-step rebinding pass.

8000-deep chain: 10286 ms -> 496 ms (~21x). All 87 tests pass with 100%
coverage. See benchmarks/INVESTIGATION.md for the full write-up and
benchmarks/provides-chain.ts (npm run bench) to reproduce.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Internal chain-building callers (providesService, copy,
provides(Container|PartialContainer)) always feed pre-memoized factories.
Route them through a trusted private static factory that bypasses the
constructor's per-step scan entirely. The public constructor keeps its
backwards-compatible memoize-as-needed behaviour for raw user input.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PartialContainer.provides() had the same O(N²) spread pattern. Apply the
same prototype-chain extension via Object.create on this.injectables.
PartialContainer chain at 8000 services: 5199 ms -> 513 ms (~10x).

Also: for...in and naive [k] lookup on a deep Object.create chain are
themselves O(N²) in V8 (key deduplication). Introduce a chainedForEach
helper that walks the chain in a single linear pass, and use it from all
sites that traverse a potentially-chained map (Container.provides merge,
PartialContainer.provides + getFactories + getTokens).

The benchmark now also covers PartialContainer construction and
materialization. Investigation doc updated with the new numbers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CI's format:check (prettier -l *) was failing on the changes from earlier
commits. No functional changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The factories map was inheriting from Object.prototype, so c.get("toString")
on a container that hadn't registered the token would silently return the
result of Object.prototype.toString instead of throwing — a regression
introduced when we switched to Object.create-based chains. Root every
factory chain at a null-prototype object so token names that match
Object.prototype methods don't leak through get().

Add tests for four use cases that 100% line coverage didn't pin down:
- A service that declares $container as a dependency receives the
  container that resolved it (including after a fork with override).
- Tokens that shadow Object.prototype methods (toString, hasOwnProperty,
  constructor) resolve to their registered values, while unregistered
  ones throw.
- A 100-deep linear dependency chain resolves to the correct values.
- copy(['token']) on a deeply chained container produces a fresh
  memoization for the scoped service while non-scoped services stay
  shared with the original.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR-introduced regression: switching from `{...this.factories}` to
`Object.create(this.factories)` made provides() construction O(1) per step
but turned every Container.get() into a prototype-chain walk costing
O(depth-of-token). V8's inline caches hide it; on Hermes (Snap mobile /
Composer, no JIT) the team measured a ~20x regression on a full key sweep
of an 800-deep chain.

Fix: materialize a flat own-property snapshot of `factories` on the first
get() and read from it thereafter. The factories chain itself stays
prototype-linked so provides() construction remains O(1). Container
instances are immutable after construction, so the cached snapshot is
valid for the container's lifetime.

Hot read path full sweep on V8 (Node): N=1600 drops from 13.3 ms/iter to
0.10 ms/iter (~129x). Curve is now linear in N. Construction numbers
unchanged (within ~10% noise).

Adds benchmarks/get-pass.ts + `npm run bench:get` to track this in CI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drop the separate `bench:get` script; `npm run bench` now runs both
construction and read-path benchmarks back-to-back via benchmarks/index.ts.
One command, complete perf picture.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three regressions surfaced in PR review, all rooted in the
Object.create-based chain leaking into externally observable behavior:

1. Container.factories observable shape changed. It was a flat
   own-property map; the chain switch made Object.keys(c.factories) return
   only the most recent layer. Fix: rename internal storage to
   `factoriesChain` (private) and expose `factories` as a lazy flat getter
   built on first access. External readers see the full set of tokens
   again; internal chain extension and the existing flat-cache lookup in
   get() continue to work unchanged.

2. Constructor skipped memoizing inherited factories. The slow path only
   scanned Object.keys(input), so `new Container(Object.create(parent))`
   left inherited raw factories un-memoized — breaking singleton semantics
   and crashing copy() (no .delegate to un-memoize). Fix: iterate via
   chainedForEach so own + inherited are both memoized into a clean
   null-rooted own-property root.

3. PartialContainer.__proto__ tokens disappeared. addInjectable did
   `Object.create(this.injectables)` where this.injectables inherited
   Object.prototype, so assigning to "__proto__" triggered the inherited
   setter and silently mutated the prototype. Fix: same null-rooted
   normalization as Container, plus a private withInjectables fast path
   that skips re-normalization for internal chain builders. Also
   null-rooted the temporary factories object built inside
   PartialContainer.getFactories so the materialized form preserves
   "__proto__" too.

Adds one test per regression. 100% coverage holds at 96/96.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Before PR #19 the public Container.factories map held memoized factories
that carried a `thisArg`, so calling `c.factories.svc()` directly (without
going through `get()`) would still resolve dependencies through the
container. After the chain switch the factory body uses call-site `this`,
which means a direct invocation makes `this` the factories map itself —
`this.get(...)` then throws for any service with dependencies.

Fix in `buildFlatFactories`: when materializing the public flat view, wrap
each factory in a tiny forwarder that calls the underlying memoized
factory with the container as `this`. Memo state stays in the underlying
factory's closure (so memoization is preserved and shared across the
chain), and both `get()` and a direct `c.factories[token]()` now route
through the same container — matching the old public contract.

Adds a test that exercises a dependent service through the public
factories map. 97/97 tests pass, 100% coverage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pin down the public-contract properties the previous commit re-established
so they can't silently regress:
- get() and direct factories[token]() share memoization (single run).
- Direct invocation works for a service inherited via a deep chain.
- Direct invocation picks up dependency overrides applied later in the
  chain (matching the get() codepath and pre-PR thisArg behavior).

100/100 tests pass, 100% coverage held.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Rewrite the two comments that anchored their explanation to "Pre-PR-#19"
and "the pre-PR thisArg behavior" so each test describes the contract
in terms of the current API. PR numbers age out; the test names already
capture the property under test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…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>
Base automatically changed from kburov/provides-chain-perf to main May 19, 2026 14:36
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