From 83975a9d69d98a1c36933e9e4d8e7224ef528d70 Mon Sep 17 00:00:00 2001 From: Konstantin Burov Date: Fri, 15 May 2026 17:11:48 +1000 Subject: [PATCH 1/3] Add modular multibindings: bind() + compose() for cross-module contributions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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, the wire-up site composes them. API surface: - `bind()` 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) --- README.md | 128 +++++++++++++++ src/Multibinding.ts | 217 +++++++++++++++++++++++++ src/__tests__/Multibinding.spec.ts | 244 +++++++++++++++++++++++++++++ src/index.ts | 2 + 4 files changed, 591 insertions(+) create mode 100644 src/Multibinding.ts create mode 100644 src/__tests__/Multibinding.spec.ts diff --git a/README.md b/README.md index e709530..227eb49 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,133 @@ const container = Container.providesValue("plugins", [] as Plugin[]) container.get("plugins"); // [AuthPlugin, LoggingPlugin, { name: "inline", ... }] ``` +#### Modular Multibindings: contributing across module boundaries + +`appendClass` / `appendValue` work when one place owns the whole `Container` chain. They break down when you want **independent modules to contribute to the same registry without seeing each other** — the canonical plugin/middleware/extension shape. `ts-inject` solves this with `bind()` and `compose()`: + +- `bind()` starts a self-contained contribution against a registry shape (typically `typeof someContainer`, but a plain type alias works too). +- `.contributeValue` / `.contributeClass` / `.contribute` add entries to one of the registry's array tokens; dependencies declared by a class or `Injectable()` flow into the binding's phantom dependency type. +- `.build()` produces a `Multibinding` — a portable value modules can export. +- `compose(core, ...bindings)` applies every binding in order, and the compiler verifies each binding's deps are present in `core`. Missing deps surface as a `missingDeps` field on the offending binding in the type error. + +A typical layout: + +```ts +// registry.ts — one place declares the shape of the extension points. +import { Container } from "@snap/ts-inject"; + +export interface Plugin { name: string; run(): void } + +export const registry = Container + .providesValue("plugins", [] as Plugin[]) + .providesValue("middlewares", [] as ((req: Request) => Request)[]); + +export type Registry = typeof registry extends import("@snap/ts-inject").Container ? S : never; +``` + +```ts +// auth/binding.ts — a module exports its contribution as a value. +import { bind } from "@snap/ts-inject"; +import type { Registry } from "../registry"; + +class AuthPlugin { + static dependencies = ["apiKey"] as const; + readonly name = "auth"; + constructor(private apiKey: string) {} + run() { /* ... */ } +} + +export const authBinding = bind() + .contributeClass("plugins", AuthPlugin) // requires `apiKey` from the core + .build(); +``` + +```ts +// metrics/binding.ts — pre-built Injectable() works the same way. +import { Injectable, bind } from "@snap/ts-inject"; +import type { Registry } from "../registry"; + +const metricsPlugin = Injectable( + "plugins", + ["statsPrefix", "logger"] as const, + (prefix: string, logger: Logger): Plugin => ({ + name: "metrics", + run: () => logger.info(`${prefix}.requests`), + }) +); + +export const metricsBinding = bind().contribute(metricsPlugin).build(); +``` + +```ts +// app.ts — wire-up site composes the core with every contribution. +import { compose } from "@snap/ts-inject"; +import { registry } from "./registry"; +import { authBinding } from "./auth/binding"; +import { metricsBinding } from "./metrics/binding"; + +const core = registry + .providesValue("apiKey", process.env.API_KEY!) + .providesValue("statsPrefix", "app") + .providesClass("logger", ConsoleLogger); + +const app = compose(core, authBinding, metricsBinding); +app.get("plugins"); // [AuthPlugin instance, metrics plugin] +``` + +If `metricsBinding` needs `statsPrefix` and the core doesn't provide it, `compose` rejects the call at compile time — the error names the missing key, not just "type mismatch". + +##### Private services with `withInternal` + +A binding's contribution often needs a helper that isn't a registry entry — say, an `HttpPlugin` that takes a `RetryPolicy`. You have three options: + +1. **Hard-code it** (`new RetryPolicy(3)` in the constructor). No DI, no config, no test seam. +2. **Add `retryPolicy` to the core container.** Now every other binding can see it, the core's service type lists it, and you've leaked one plugin's implementation detail into the global namespace. +3. **`withInternal`.** Attach a small `PartialContainer` of helpers that *only this binding's contributions* can see. The helper is properly DI-wired, but invisible to other bindings and to consumers of the composed container. + +```ts +import { bind, PartialContainer } from "@snap/ts-inject"; + +const internal = new PartialContainer({}).provides( + "retryPolicy", + ["maxRetries"] as const, + (n: number) => new RetryPolicy(n) +); + +class HttpPlugin { + static dependencies = ["retryPolicy", "endpoint"] as const; + readonly name = "http"; + constructor(private retry: RetryPolicy, private endpoint: string) {} + run() { /* ... */ } +} + +export const httpBinding = bind() + .withInternal(internal) + .contributeClass("plugins", HttpPlugin) + .build(); +``` + +**Dep-flow rule:** a binding requires whatever its contributions declare as deps, **minus** what `withInternal` provides, **plus** what `withInternal` itself depends on but doesn't provide. Above: + +- `HttpPlugin` declares `["retryPolicy", "endpoint"]`. +- `withInternal` provides `retryPolicy`, and itself needs `maxRetries`. +- → `compose(core, httpBinding)` requires `{ endpoint, maxRetries }` from the core. `retryPolicy` is satisfied internally and doesn't appear. + +The privacy is enforced on both axes: `compose`'s return type doesn't include `retryPolicy` (so `app.get("retryPolicy")` is a type error), and the helper is only resolvable inside this binding's apply step at runtime. + +Chain `.withInternal(...)` **before** the contributions that depend on its services — the dep subtraction only sees what's been declared so far. + +##### When to use which + +| You want to… | Use | +| --------------------------------------------------------------------------- | ----------------------------------------- | +| Append to an array in a `Container` you own end-to-end | `container.appendValue` / `appendClass` | +| Let several modules independently extend a shared registry | `bind()` + `compose()` | +| Contribute a value with no DI | `.contributeValue(token, value)` | +| Contribute a class whose deps live in the core | `.contributeClass(token, MyClass)` | +| Contribute a custom factory (closes over state, composes services manually) | `.contribute(Injectable(...))` | +| Inject a helper that's an implementation detail of one binding | `.withInternal(partialContainer)` | + ### Key Concepts - **Container**: A registry for all services, handling their creation and retrieval. @@ -119,6 +246,7 @@ container.get("plugins"); // [AuthPlugin, LoggingPlugin, { name: "inline", ... } - **Token**: A unique identifier for each service, used for registration and retrieval within the Container. - **InjectableClass**: Classes that can be instantiated by the Container. Dependencies are specified in a static `dependencies` field to enable automatic injection via `providesClass`. - **InjectableFunction**: A reusable factory object created by `Injectable()`. Rarely needed directly — prefer the inline `provides('token', factory)` form. Use `Injectable()` when you need to store or pass a factory to `run()`. +- **Multibinding**: A portable, type-branded contribution to a registry container's array-typed tokens, produced by `bind()` and applied via `compose()`. Lets independent modules extend a shared registry without sharing a Container chain. ### API Reference diff --git a/src/Multibinding.ts b/src/Multibinding.ts new file mode 100644 index 0000000..b962f17 --- /dev/null +++ b/src/Multibinding.ts @@ -0,0 +1,217 @@ +import type { Container } from "./Container"; +import type { PartialContainer } from "./PartialContainer"; +import type { InjectableClass, InjectableFunction } from "./types"; + +type ElementOf = T extends readonly (infer E)[] ? E : never; + +/** Tokens of `S` whose service type is a readonly array — the only tokens a multibinding can contribute to. */ +type ArrayTokens = { [K in keyof S]: S[K] extends readonly unknown[] ? K : never }[keyof S]; + +type DepsForClass = C extends { readonly dependencies: readonly (infer K extends string)[] } + ? Record + : {}; + +type DepsForInjectable = F extends InjectableFunction + ? Tokens extends readonly (infer K extends string)[] + ? Record + : {} + : {}; + +type ServicesOf = I extends PartialContainer ? S : never; +type InternalDeps = I extends PartialContainer ? D : never; + +/** + * A reified, type-branded contribution to a registry shape `S`, requiring extra dependencies `D` + * from the core container at compose time. + * + * Multibindings are produced by {@link bind} and applied via {@link compose}. They let separate + * modules contribute to the same array-typed registry tokens (e.g. `plugins`, `middlewares`) + * without sharing a Container chain — the contributions are values that can be exported, + * imported, and composed in one place. + * + * `D` is phantom: it carries the union of dependency keys the binding's contributions need, + * so `compose` can verify the core container satisfies them. + */ +export type Multibinding = ((core: Container) => Container) & { + readonly __deps?: D; +}; + +/** + * Type-level validator for `compose`: passes a binding through unchanged if its phantom deps + * are satisfied by the core's services, otherwise tags it with a `missingDeps` field naming + * the keys it needs. + */ +type Validated = Mb extends Multibinding + ? unknown extends D + ? Mb + : keyof D extends keyof Core + ? Mb + : Mb & { readonly missingDeps: Exclude } + : never; + +/** + * Builder for a single {@link Multibinding}. Tracks four type parameters: + * - `S` — the registry shape (phantom; usually `typeof registryContainer`). + * - `Internal` — services brought along via {@link MultibindingBuilder.withInternal}, used to + * satisfy contribution dependencies without forwarding them to the core container. + * - `IDeps` — unresolved dependencies of the internal `PartialContainer`, which *do* flow + * to the core. + * - `Deps` — dependencies of class/injectable contributions that aren't satisfied by + * `Internal`, which also flow to the core. + * + * The final {@link Multibinding} aggregates `IDeps & Deps` as its phantom `D`. + */ +export class MultibindingBuilder { + constructor(private readonly apply: (core: Container) => Container) {} + + /** + * Contribute a literal value to one of the registry's array tokens. + * + * @example + * ```ts + * bind().contributeValue("plugins", inlinePlugin).build(); + * ``` + */ + contributeValue>( + token: T, + value: ElementOf + ): MultibindingBuilder { + return new MultibindingBuilder((c) => + this.apply(c).appendValue(token as never, value as never) + ); + } + + /** + * Contribute an instance of a class to one of the registry's array tokens. The class's + * `static dependencies` are added to the binding's required deps and validated at + * {@link compose} time — dependencies already provided by a {@link withInternal} are subtracted. + * + * @example + * ```ts + * class AuthPlugin { + * static dependencies = ["config"] as const; + * constructor(private config: Config) {} + * } + * + * bind().contributeClass("plugins", AuthPlugin).build(); + * ``` + */ + contributeClass< + T extends ArrayTokens, + Class extends InjectableClass, readonly string[]>, + >( + token: T, + cls: Class + ): MultibindingBuilder, keyof Internal>> { + return new MultibindingBuilder, keyof Internal>>( + (c) => this.apply(c).appendClass(token as never, cls as never) + ); + } + + /** + * Contribute a pre-built {@link InjectableFunction} to a registry token. The injectable's + * own token determines which array it contributes to, and its declared `dependencies` are + * added to the binding's required deps (minus anything already provided by {@link withInternal}). + * + * Use this when a contribution needs a non-trivial factory — e.g. one that closes over + * configuration or composes other services — but doesn't fit the class shape. + * + * @example + * ```ts + * import { Injectable } from "@snap/ts-inject"; + * + * const metricsPlugin = Injectable( + * "plugins", + * ["config", "logger"] as const, + * (config: Config, logger: Logger) => new MetricsPlugin(config, logger) + * ); + * + * bind().contribute(metricsPlugin).build(); + * ``` + */ + contribute< + T extends ArrayTokens, + F extends InjectableFunction>, + >( + fn: F + ): MultibindingBuilder, keyof Internal>> { + return new MultibindingBuilder, keyof Internal>>( + (c) => this.apply(c).append(fn as never) + ); + } + + /** + * Attach a {@link PartialContainer} of private services that subsequent contributions in this + * binding can depend on. The partial's *unresolved* dependencies become required deps of the + * binding; its provided services are visible to later `contributeClass` / `contribute` calls + * but not exposed outside the binding's runtime scope. + * + * Chain `withInternal` **before** the contributions that depend on its services so the + * compile-time dep subtraction has the necessary information. + * + * @example + * ```ts + * const internal = new PartialContainer({}) + * .provides("retryPolicy", ["config"] as const, (c: Config) => new RetryPolicy(c)); + * + * bind() + * .withInternal(internal) + * .contributeClass("plugins", HttpPlugin) // depends on retryPolicy + anything in core + * .build(); + * ``` + */ + withInternal>( + internal: I + ): MultibindingBuilder, IDeps & InternalDeps, Omit>> { + return new MultibindingBuilder< + S, + Internal & ServicesOf, + IDeps & InternalDeps, + Omit> + >((c) => this.apply(c.provides(internal) as Container)); + } + + /** Finalize this builder into a portable {@link Multibinding} value. */ + build(): Multibinding { + return this.apply as Multibinding; + } +} + +/** + * Start a multibinding against a registry shape `S`. + * + * `S` is a phantom — pass `typeof registry` when you already have a Container that declares the + * array tokens, or a hand-written shape (e.g. `{ plugins: Plugin[] }`) when you don't yet. + * + * @example + * ```ts + * type Registry = { plugins: Plugin[]; middlewares: Middleware[] }; + * + * export const authBinding = bind() + * .contributeClass("plugins", AuthPlugin) + * .build(); + * ``` + */ +export function bind(): MultibindingBuilder { + return new MultibindingBuilder((c) => c); +} + +/** + * Apply a list of {@link Multibinding}s to a core container in order, returning a container of + * the same type. The compiler verifies that every binding's phantom dependencies are present in + * the core; missing deps appear as a `missingDeps` field on the offending binding in the error. + * + * @example + * ```ts + * const app = compose(core, authBinding, loggingBinding, metricsBinding); + * ``` + */ +export function compose[]>( + core: Container, + ...bindings: { [I in keyof Mbs]: Validated } +): Container { + return (bindings as readonly Multibinding[]).reduce>( + (c, mb) => (mb as (c: Container) => Container)(c), + core + ) as Container; +} diff --git a/src/__tests__/Multibinding.spec.ts b/src/__tests__/Multibinding.spec.ts new file mode 100644 index 0000000..2158b1f --- /dev/null +++ b/src/__tests__/Multibinding.spec.ts @@ -0,0 +1,244 @@ +/* eslint-disable max-classes-per-file */ +import { Container } from "../Container"; +import { Injectable } from "../Injectable"; +import { PartialContainer } from "../PartialContainer"; +import { bind, compose } from "../Multibinding"; +import type { Multibinding } from "../Multibinding"; + +interface Plugin { + name: string; + run(): string; +} + +type Registry = { + plugins: Plugin[]; + middlewares: ((req: string) => string)[]; +}; + +describe("Multibinding", () => { + describe("bind().contributeValue", () => { + test("appends a literal value to a registry token", () => { + const inline: Plugin = { name: "inline", run: () => "inline" }; + const binding = bind().contributeValue("plugins", inline).build(); + + const core = Container.providesValue("plugins", [] as Plugin[]); + const result = compose(core, binding); + + expect(result.get("plugins")).toEqual([inline]); + }); + + test("multiple contributeValue calls preserve insertion order", () => { + const a: Plugin = { name: "a", run: () => "a" }; + const b: Plugin = { name: "b", run: () => "b" }; + const binding = bind() + .contributeValue("plugins", a) + .contributeValue("plugins", b) + .build(); + + const core = Container.providesValue("plugins", [] as Plugin[]); + expect(compose(core, binding).get("plugins")).toEqual([a, b]); + }); + }); + + describe("bind().contributeClass", () => { + test("instantiates the class with deps resolved from the core container", () => { + class AuthPlugin implements Plugin { + static dependencies = ["apiKey"] as const; + readonly name = "auth"; + constructor(private apiKey: string) {} + run() { + return `auth:${this.apiKey}`; + } + } + + const binding = bind().contributeClass("plugins", AuthPlugin).build(); + const core = Container.providesValue("apiKey", "secret").providesValue("plugins", [] as Plugin[]); + const result = compose(core, binding); + + expect(result.get("plugins").map((p) => p.run())).toEqual(["auth:secret"]); + }); + + test("compose reports missing deps as a type error", () => { + class NeedsConfig implements Plugin { + static dependencies = ["config"] as const; + readonly name = "needs-config"; + constructor(private config: { url: string }) {} + run() { + return this.config.url; + } + } + + const binding = bind().contributeClass("plugins", NeedsConfig).build(); + const core = Container.providesValue("plugins", [] as Plugin[]); + + // @ts-expect-error: core does not provide "config" + compose(core, binding); + }); + }); + + describe("bind().contribute(Injectable)", () => { + test("appends a pre-built InjectableFunction, resolving its deps from the core", () => { + const metricsPlugin = Injectable( + "plugins", + ["statsPrefix"] as const, + (prefix: string): Plugin => ({ name: "metrics", run: () => `${prefix}.requests` }) + ); + + const binding = bind().contribute(metricsPlugin).build(); + const core = Container.providesValue("statsPrefix", "app").providesValue("plugins", [] as Plugin[]); + + expect(compose(core, binding).get("plugins").map((p) => p.run())).toEqual(["app.requests"]); + }); + + test("zero-dep Injectable", () => { + const ping = Injectable("plugins", (): Plugin => ({ name: "ping", run: () => "pong" })); + const binding = bind().contribute(ping).build(); + const core = Container.providesValue("plugins", [] as Plugin[]); + + expect(compose(core, binding).get("plugins").map((p) => p.run())).toEqual(["pong"]); + }); + }); + + describe("bind().withInternal", () => { + test("subtracts services provided by the partial from the required deps", () => { + class HttpPlugin implements Plugin { + static dependencies = ["retryPolicy", "endpoint"] as const; + readonly name = "http"; + constructor(private retry: { tries: number }, private endpoint: string) {} + run() { + return `${this.endpoint}#${this.retry.tries}`; + } + } + + const internal = new PartialContainer({}).provides( + "retryPolicy", + ["maxRetries"] as const, + (n: number) => ({ tries: n }) + ); + + // `retryPolicy` is satisfied by the internal partial; `endpoint` and `maxRetries` + // must come from the core container. + const binding = bind().withInternal(internal).contributeClass("plugins", HttpPlugin).build(); + const core = Container.providesValue("endpoint", "https://api.example.com") + .providesValue("maxRetries", 3) + .providesValue("plugins", [] as Plugin[]); + + expect(compose(core, binding).get("plugins").map((p) => p.run())).toEqual([ + "https://api.example.com#3", + ]); + }); + + test("compose still flags unresolved internal dependencies", () => { + class HttpPlugin implements Plugin { + static dependencies = ["retryPolicy"] as const; + readonly name = "http"; + constructor(private retry: { tries: number }) {} + run() { + return `tries:${this.retry.tries}`; + } + } + const internal = new PartialContainer({}).provides( + "retryPolicy", + ["maxRetries"] as const, + (n: number) => ({ tries: n }) + ); + const binding = bind().withInternal(internal).contributeClass("plugins", HttpPlugin).build(); + + const core = Container.providesValue("plugins", [] as Plugin[]); + // @ts-expect-error: core is missing "maxRetries", which the internal partial needs + compose(core, binding); + }); + }); + + describe("compose", () => { + test("applies multibindings left-to-right against a shared registry", () => { + const inline: Plugin = { name: "inline", run: () => "inline" }; + const first = bind().contributeValue("plugins", inline).build(); + + class Logging implements Plugin { + static dependencies = ["label"] as const; + readonly name = "logging"; + constructor(private label: string) {} + run() { + return `log:${this.label}`; + } + } + const second = bind().contributeClass("plugins", Logging).build(); + + const core = Container.providesValue("label", "dev").providesValue("plugins", [] as Plugin[]); + const result = compose(core, first, second); + + expect(result.get("plugins").map((p) => p.run())).toEqual(["inline", "log:dev"]); + }); + + test("returns a Container of the original Core type — internal services do not leak into types", () => { + const internal = new PartialContainer({}).providesValue("secret", "hidden"); + const binding = bind() + .withInternal(internal) + .contributeValue("plugins", { name: "x", run: () => "x" }) + .build(); + + const core = Container.providesValue("plugins", [] as Plugin[]); + const result = compose(core, binding); + + // `secret` is reachable at runtime via PartialContainer's merge, but not in the type. + // @ts-expect-error: "secret" is not part of Core's service surface + result.get("secret"); + }); + + test("contributions to different array tokens compose independently", () => { + const upper: (req: string) => string = (r) => r.toUpperCase(); + const trim: (req: string) => string = (r) => r.trim(); + + const pluginBinding = bind() + .contributeValue("plugins", { name: "noop", run: () => "noop" }) + .build(); + const mwBinding = bind() + .contributeValue("middlewares", upper) + .contributeValue("middlewares", trim) + .build(); + + const core = Container.providesValue("plugins", [] as Plugin[]).providesValue( + "middlewares", + [] as ((req: string) => string)[] + ); + const result = compose(core, pluginBinding, mwBinding); + + expect(result.get("plugins")).toHaveLength(1); + expect(result.get("middlewares").map((m) => m(" hi "))).toEqual([" HI ", "hi"]); + }); + + test("the same binding can be applied to multiple cores", () => { + class Counter implements Plugin { + static dependencies = ["base"] as const; + readonly name = "counter"; + constructor(private base: number) {} + run() { + return `${this.base + 1}`; + } + } + const binding: Multibinding = bind() + .contributeClass("plugins", Counter) + .build(); + + const coreA = Container.providesValue("base", 10).providesValue("plugins", [] as Plugin[]); + const coreB = Container.providesValue("base", 100).providesValue("plugins", [] as Plugin[]); + + expect(compose(coreA, binding).get("plugins")[0].run()).toBe("11"); + expect(compose(coreB, binding).get("plugins")[0].run()).toBe("101"); + }); + }); + + describe("type-level guards", () => { + test("contributeValue rejects non-array tokens", () => { + type Bad = { single: Plugin }; + // @ts-expect-error: "single" is not an array-typed token + bind().contributeValue("single", { name: "x", run: () => "x" }); + }); + + test("contributeValue rejects values whose type does not match the array element", () => { + // @ts-expect-error: number is not assignable to Plugin + bind().contributeValue("plugins", 42); + }); + }); +}); diff --git a/src/index.ts b/src/index.ts index d17449d..47929dc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,6 @@ export { CONTAINER, Container } from "./Container"; export { Injectable, InjectableCompat, ConcatInjectable } from "./Injectable"; export { PartialContainer } from "./PartialContainer"; +export { bind, compose, MultibindingBuilder } from "./Multibinding"; +export type { Multibinding } from "./Multibinding"; export { InjectableFunction, InjectableClass, ServicesFromInjectables } from "./types"; From d057cf40fd153653c0896606ab29ca766791c462 Mon Sep 17 00:00:00 2001 From: Konstantin Burov Date: Mon, 18 May 2026 18:12:26 +1000 Subject: [PATCH 2/3] Replace MultibindingBuilder with multibindings()/combine()/withInternal() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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()` 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` brand and `compose`'s `missingDeps` error semantics are preserved. Why the swap: - Single-contribution modules (the common case) collapse from `bind().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(...)`: TypeScript doesn't allow partial type-arg application — `contribute(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`). Routing through `multibindings(registry)` (or `multibindings()`) 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 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) --- README.md | 80 +++++---- src/Multibinding.ts | 274 +++++++++++++++-------------- src/__tests__/Multibinding.spec.ts | 205 ++++++++++++++------- src/index.ts | 4 +- 4 files changed, 332 insertions(+), 231 deletions(-) diff --git a/README.md b/README.md index 227eb49..38563c3 100644 --- a/README.md +++ b/README.md @@ -113,18 +113,18 @@ container.get("plugins"); // [AuthPlugin, LoggingPlugin, { name: "inline", ... } #### Modular Multibindings: contributing across module boundaries -`appendClass` / `appendValue` work when one place owns the whole `Container` chain. They break down when you want **independent modules to contribute to the same registry without seeing each other** — the canonical plugin/middleware/extension shape. `ts-inject` solves this with `bind()` and `compose()`: +`appendClass` / `appendValue` work when one place owns the whole `Container` chain. They break down when you want **independent modules to contribute to the same registry without seeing each other** — the canonical plugin/middleware/extension shape. `ts-inject` solves this with `multibindings()` and `compose()`: -- `bind()` starts a self-contained contribution against a registry shape (typically `typeof someContainer`, but a plain type alias works too). -- `.contributeValue` / `.contributeClass` / `.contribute` add entries to one of the registry's array tokens; dependencies declared by a class or `Injectable()` flow into the binding's phantom dependency type. -- `.build()` produces a `Multibinding` — a portable value modules can export. -- `compose(core, ...bindings)` applies every binding in order, and the compiler verifies each binding's deps are present in `core`. Missing deps surface as a `missingDeps` field on the offending binding in the type error. +- `multibindings(registry)` (or `multibindings()` if you only have a type) returns a small factory bound to the registry shape. Its `contribute(...)` method produces a `Multibinding` — a portable value modules can export. +- `compose(core, ...bindings)` applies every binding to a core container in order. The compiler verifies each binding's `Deps` are present in `core`; missing keys surface as a `missingDeps` field on the offending binding rather than as a generic type mismatch. +- `combine(...bindings)` bundles several `Multibinding`s into one — handy when a module ships a small family of contributions. +- `withInternal(partialContainer, ...bindings)` attaches private helpers visible only to the contributions inside the call. A typical layout: ```ts // registry.ts — one place declares the shape of the extension points. -import { Container } from "@snap/ts-inject"; +import { Container, multibindings } from "@snap/ts-inject"; export interface Plugin { name: string; run(): void } @@ -132,13 +132,13 @@ export const registry = Container .providesValue("plugins", [] as Plugin[]) .providesValue("middlewares", [] as ((req: Request) => Request)[]); -export type Registry = typeof registry extends import("@snap/ts-inject").Container ? S : never; +// Shared factory bound to the registry shape — modules import this. +export const m = multibindings(registry); ``` ```ts // auth/binding.ts — a module exports its contribution as a value. -import { bind } from "@snap/ts-inject"; -import type { Registry } from "../registry"; +import { m } from "../registry"; class AuthPlugin { static dependencies = ["apiKey"] as const; @@ -147,15 +147,13 @@ class AuthPlugin { run() { /* ... */ } } -export const authBinding = bind() - .contributeClass("plugins", AuthPlugin) // requires `apiKey` from the core - .build(); +export const authBinding = m.contribute("plugins", AuthPlugin); // requires `apiKey` from core ``` ```ts // metrics/binding.ts — pre-built Injectable() works the same way. -import { Injectable, bind } from "@snap/ts-inject"; -import type { Registry } from "../registry"; +import { Injectable } from "@snap/ts-inject"; +import { m } from "../registry"; const metricsPlugin = Injectable( "plugins", @@ -166,7 +164,7 @@ const metricsPlugin = Injectable( }) ); -export const metricsBinding = bind().contribute(metricsPlugin).build(); +export const metricsBinding = m.contribute(metricsPlugin); ``` ```ts @@ -187,6 +185,23 @@ app.get("plugins"); // [AuthPlugin instance, metrics plugin] If `metricsBinding` needs `statsPrefix` and the core doesn't provide it, `compose` rejects the call at compile time — the error names the missing key, not just "type mismatch". +##### Bundling multiple contributions: `combine` + +When one module wants to export several related contributions, wrap them in `combine` instead of exporting each separately: + +```ts +import { combine } from "@snap/ts-inject"; +import { m } from "../registry"; + +export const authBindings = combine( + m.contribute("plugins", AuthPlugin), + m.contribute("plugins", OAuthPlugin), + m.contribute(authMetricsInjectable), +); +``` + +The result is a single `Multibinding` whose deps are the union of the inputs' deps. Wire-up still passes it as one argument to `compose`. + ##### Private services with `withInternal` A binding's contribution often needs a helper that isn't a registry entry — say, an `HttpPlugin` that takes a `RetryPolicy`. You have three options: @@ -196,9 +211,10 @@ A binding's contribution often needs a helper that isn't a registry entry — sa 3. **`withInternal`.** Attach a small `PartialContainer` of helpers that *only this binding's contributions* can see. The helper is properly DI-wired, but invisible to other bindings and to consumers of the composed container. ```ts -import { bind, PartialContainer } from "@snap/ts-inject"; +import { PartialContainer, withInternal } from "@snap/ts-inject"; +import { m } from "../registry"; -const internal = new PartialContainer({}).provides( +const retryInternal = new PartialContainer({}).provides( "retryPolicy", ["maxRetries"] as const, (n: number) => new RetryPolicy(n) @@ -211,32 +227,32 @@ class HttpPlugin { run() { /* ... */ } } -export const httpBinding = bind() - .withInternal(internal) - .contributeClass("plugins", HttpPlugin) - .build(); +export const httpBinding = withInternal(retryInternal, + m.contribute("plugins", HttpPlugin), +); ``` **Dep-flow rule:** a binding requires whatever its contributions declare as deps, **minus** what `withInternal` provides, **plus** what `withInternal` itself depends on but doesn't provide. Above: - `HttpPlugin` declares `["retryPolicy", "endpoint"]`. -- `withInternal` provides `retryPolicy`, and itself needs `maxRetries`. +- `retryInternal` provides `retryPolicy` and itself needs `maxRetries`. - → `compose(core, httpBinding)` requires `{ endpoint, maxRetries }` from the core. `retryPolicy` is satisfied internally and doesn't appear. The privacy is enforced on both axes: `compose`'s return type doesn't include `retryPolicy` (so `app.get("retryPolicy")` is a type error), and the helper is only resolvable inside this binding's apply step at runtime. -Chain `.withInternal(...)` **before** the contributions that depend on its services — the dep subtraction only sees what's been declared so far. +Subtraction is non-positional — every binding inside the `withInternal(...)` call sees the partial, regardless of argument order. ##### When to use which -| You want to… | Use | -| --------------------------------------------------------------------------- | ----------------------------------------- | -| Append to an array in a `Container` you own end-to-end | `container.appendValue` / `appendClass` | -| Let several modules independently extend a shared registry | `bind()` + `compose()` | -| Contribute a value with no DI | `.contributeValue(token, value)` | -| Contribute a class whose deps live in the core | `.contributeClass(token, MyClass)` | -| Contribute a custom factory (closes over state, composes services manually) | `.contribute(Injectable(...))` | -| Inject a helper that's an implementation detail of one binding | `.withInternal(partialContainer)` | +| You want to… | Use | +| --------------------------------------------------------------------------- | ------------------------------------------------ | +| Append to an array in a `Container` you own end-to-end | `container.appendValue` / `appendClass` | +| Let several modules independently extend a shared registry | `multibindings(registry)` + `compose(core, ...)` | +| Contribute a value with no DI | `m.contribute(token, value)` | +| Contribute a class whose deps live in the core | `m.contribute(token, MyClass)` | +| Contribute a custom factory (closes over state, composes services manually) | `m.contribute(Injectable(...))` | +| Bundle several contributions from one module | `combine(mb1, mb2, …)` | +| Inject a helper that's an implementation detail of one binding | `withInternal(partial, mb1, mb2, …)` | ### Key Concepts @@ -246,7 +262,7 @@ Chain `.withInternal(...)` **before** the contributions that depend on its servi - **Token**: A unique identifier for each service, used for registration and retrieval within the Container. - **InjectableClass**: Classes that can be instantiated by the Container. Dependencies are specified in a static `dependencies` field to enable automatic injection via `providesClass`. - **InjectableFunction**: A reusable factory object created by `Injectable()`. Rarely needed directly — prefer the inline `provides('token', factory)` form. Use `Injectable()` when you need to store or pass a factory to `run()`. -- **Multibinding**: A portable, type-branded contribution to a registry container's array-typed tokens, produced by `bind()` and applied via `compose()`. Lets independent modules extend a shared registry without sharing a Container chain. +- **Multibinding**: A portable, type-branded contribution to a registry container's array-typed tokens, produced by `multibindings(registry).contribute(...)` and applied via `compose()`. Lets independent modules extend a shared registry without sharing a Container chain. ### API Reference diff --git a/src/Multibinding.ts b/src/Multibinding.ts index b962f17..0014918 100644 --- a/src/Multibinding.ts +++ b/src/Multibinding.ts @@ -20,26 +20,30 @@ type DepsForInjectable = F extends InjectableFunction = I extends PartialContainer ? S : never; type InternalDeps = I extends PartialContainer ? D : never; +/** Aggregate the phantom `D` of a tuple of Multibindings into a single dependency record. */ +type UnionDeps = Mbs extends readonly [infer H, ...infer R] + ? (H extends Multibinding ? D : {}) & UnionDeps + : {}; + /** * A reified, type-branded contribution to a registry shape `S`, requiring extra dependencies `D` * from the core container at compose time. * - * Multibindings are produced by {@link bind} and applied via {@link compose}. They let separate - * modules contribute to the same array-typed registry tokens (e.g. `plugins`, `middlewares`) - * without sharing a Container chain — the contributions are values that can be exported, - * imported, and composed in one place. + * Multibindings are values that can be exported, imported, combined, and finally applied with + * {@link compose}. They let separate modules contribute to the same array-typed registry tokens + * (e.g. `plugins`, `middlewares`) without sharing a Container chain. * - * `D` is phantom: it carries the union of dependency keys the binding's contributions need, - * so `compose` can verify the core container satisfies them. + * `D` is phantom: it carries the union of dependency keys the contribution needs, so `compose` + * can verify the core container satisfies them. */ export type Multibinding = ((core: Container) => Container) & { readonly __deps?: D; }; /** - * Type-level validator for `compose`: passes a binding through unchanged if its phantom deps - * are satisfied by the core's services, otherwise tags it with a `missingDeps` field naming - * the keys it needs. + * Type-level validator for `compose`: passes a binding through unchanged if its phantom deps are + * satisfied by the core's services, otherwise tags it with a `missingDeps` field naming the keys + * it needs. */ type Validated = Mb extends Multibinding ? unknown extends D @@ -50,150 +54,150 @@ type Validated = Mb extends Multibinding : never; /** - * Builder for a single {@link Multibinding}. Tracks four type parameters: - * - `S` — the registry shape (phantom; usually `typeof registryContainer`). - * - `Internal` — services brought along via {@link MultibindingBuilder.withInternal}, used to - * satisfy contribution dependencies without forwarding them to the core container. - * - `IDeps` — unresolved dependencies of the internal `PartialContainer`, which *do* flow - * to the core. - * - `Deps` — dependencies of class/injectable contributions that aren't satisfied by - * `Internal`, which also flow to the core. + * Family of multibinding helpers bound to a specific registry shape `S`. * - * The final {@link Multibinding} aggregates `IDeps & Deps` as its phantom `D`. + * Obtained from {@link multibindings} — either by passing a Container so `S` is inferred from + * its services, or by supplying `S` as a type argument when no Container instance is available. */ -export class MultibindingBuilder { - constructor(private readonly apply: (core: Container) => Container) {} - +export interface MultibindingFactory { /** - * Contribute a literal value to one of the registry's array tokens. - * - * @example - * ```ts - * bind().contributeValue("plugins", inlinePlugin).build(); - * ``` + * Produce a {@link Multibinding}. Three call shapes: + * - `contribute(token, value)` — appends a literal value. + * - `contribute(token, ClassWithDeps)` — appends an instance built from an + * {@link InjectableClass}; its `static dependencies` become the binding's required deps. + * - `contribute(injectable)` — appends a value produced by a pre-built + * {@link InjectableFunction}; the function's `token` selects the array, and its declared + * `dependencies` become the binding's required deps. */ - contributeValue>( - token: T, - value: ElementOf - ): MultibindingBuilder { - return new MultibindingBuilder((c) => - this.apply(c).appendValue(token as never, value as never) - ); - } + contribute: { + < + T extends ArrayTokens, + F extends InjectableFunction>, + >( + injectable: F + ): Multibinding>; + < + T extends ArrayTokens, + Class extends InjectableClass, readonly string[]>, + >( + token: T, + cls: Class + ): Multibinding>; + >(token: T, value: ElementOf): Multibinding; + }; +} - /** - * Contribute an instance of a class to one of the registry's array tokens. The class's - * `static dependencies` are added to the binding's required deps and validated at - * {@link compose} time — dependencies already provided by a {@link withInternal} are subtracted. - * - * @example - * ```ts - * class AuthPlugin { - * static dependencies = ["config"] as const; - * constructor(private config: Config) {} - * } - * - * bind().contributeClass("plugins", AuthPlugin).build(); - * ``` - */ - contributeClass< - T extends ArrayTokens, - Class extends InjectableClass, readonly string[]>, - >( - token: T, - cls: Class - ): MultibindingBuilder, keyof Internal>> { - return new MultibindingBuilder, keyof Internal>>( - (c) => this.apply(c).appendClass(token as never, cls as never) - ); +function contributeImpl(first: unknown, second?: unknown): Multibinding { + // 1-arg form: contribute(injectable). The injectable carries its own token. + if (second === undefined) { + const fn = first as InjectableFunction; + return ((core: Container) => core.append(fn as never)) as Multibinding; } - - /** - * Contribute a pre-built {@link InjectableFunction} to a registry token. The injectable's - * own token determines which array it contributes to, and its declared `dependencies` are - * added to the binding's required deps (minus anything already provided by {@link withInternal}). - * - * Use this when a contribution needs a non-trivial factory — e.g. one that closes over - * configuration or composes other services — but doesn't fit the class shape. - * - * @example - * ```ts - * import { Injectable } from "@snap/ts-inject"; - * - * const metricsPlugin = Injectable( - * "plugins", - * ["config", "logger"] as const, - * (config: Config, logger: Logger) => new MetricsPlugin(config, logger) - * ); - * - * bind().contribute(metricsPlugin).build(); - * ``` - */ - contribute< - T extends ArrayTokens, - F extends InjectableFunction>, - >( - fn: F - ): MultibindingBuilder, keyof Internal>> { - return new MultibindingBuilder, keyof Internal>>( - (c) => this.apply(c).append(fn as never) - ); + const token = first as never; + // 2-arg form, class: a function carrying a `dependencies` array (Injectables also carry one, + // but those should use the 1-arg form, and would be missing the `new`-ability `appendClass` expects). + if (typeof second === "function" && Array.isArray((second as { dependencies?: unknown }).dependencies)) { + const cls = second as InjectableClass; + return ((core: Container) => core.appendClass(token, cls as never)) as Multibinding; } + // 2-arg form, value. + return ((core: Container) => core.appendValue(token, second as never)) as Multibinding; +} - /** - * Attach a {@link PartialContainer} of private services that subsequent contributions in this - * binding can depend on. The partial's *unresolved* dependencies become required deps of the - * binding; its provided services are visible to later `contributeClass` / `contribute` calls - * but not exposed outside the binding's runtime scope. - * - * Chain `withInternal` **before** the contributions that depend on its services so the - * compile-time dep subtraction has the necessary information. - * - * @example - * ```ts - * const internal = new PartialContainer({}) - * .provides("retryPolicy", ["config"] as const, (c: Config) => new RetryPolicy(c)); - * - * bind() - * .withInternal(internal) - * .contributeClass("plugins", HttpPlugin) // depends on retryPolicy + anything in core - * .build(); - * ``` - */ - withInternal>( - internal: I - ): MultibindingBuilder, IDeps & InternalDeps, Omit>> { - return new MultibindingBuilder< - S, - Internal & ServicesOf, - IDeps & InternalDeps, - Omit> - >((c) => this.apply(c.provides(internal) as Container)); - } +/** + * Capture a registry shape so subsequent contribution calls don't need to repeat it. Two forms: + * + * - `multibindings(registryContainer)` — infers `S` from the container's services. + * - `multibindings()` — when only a type alias is available. + * + * Returns a {@link MultibindingFactory} with a `contribute` method whose overloads cover values, + * {@link InjectableClass}es, and pre-built {@link InjectableFunction}s. + * + * @example + * ```ts + * // With a Container instance: + * const m = multibindings(registry); + * export const authBinding = m.contribute("plugins", AuthPlugin); + * + * // With only a type: + * const m = multibindings(); + * export const inlineBinding = m.contribute("plugins", { name: "x", run: () => "x" }); + * ``` + */ +export function multibindings(): MultibindingFactory; +export function multibindings(registry: Container): MultibindingFactory; +export function multibindings(_registry?: Container): MultibindingFactory { + return { contribute: contributeImpl as MultibindingFactory["contribute"] }; +} - /** Finalize this builder into a portable {@link Multibinding} value. */ - build(): Multibinding { - return this.apply as Multibinding; - } +/** + * Bundle several {@link Multibinding}s that target the same registry shape into one. The + * resulting binding's deps are the union of the inputs' deps and contributions are applied + * left-to-right. + * + * Use this when one module exports several related contributions and you'd rather pass a single + * value to {@link compose}. + * + * @example + * ```ts + * const m = multibindings(); + * + * export const authBindings = combine( + * m.contribute("plugins", AuthPlugin), + * m.contribute("plugins", OAuthPlugin), + * m.contribute(authMetricsInjectable), + * ); + * ``` + */ +export function combine[] = readonly []>( + ...bindings: Mbs +): Multibinding> { + return ((core: Container) => + bindings.reduce>( + (c, mb) => (mb as (c: Container) => Container)(c), + core + )) as Multibinding>; } /** - * Start a multibinding against a registry shape `S`. + * Apply contributions against a private {@link PartialContainer} of helpers that's invisible to + * other bindings and to consumers of the composed container's type. * - * `S` is a phantom — pass `typeof registry` when you already have a Container that declares the - * array tokens, or a hand-written shape (e.g. `{ plugins: Plugin[] }`) when you don't yet. + * The partial's *unresolved* dependencies flow to the core; its *provided* services are + * subtracted from the contributions' deps. Subtraction is non-positional: every binding inside + * the call sees the partial. * * @example * ```ts - * type Registry = { plugins: Plugin[]; middlewares: Middleware[] }; + * const m = multibindings(); + * const retryInternal = new PartialContainer({}).provides( + * "retryPolicy", + * ["maxRetries"] as const, + * (n: number) => new RetryPolicy(n) + * ); * - * export const authBinding = bind() - * .contributeClass("plugins", AuthPlugin) - * .build(); + * // HttpPlugin.dependencies = ["retryPolicy", "endpoint"] + * // → the composed binding requires { endpoint, maxRetries } — retryPolicy is internal. + * export const httpBinding = withInternal(retryInternal, + * m.contribute("plugins", HttpPlugin), + * ); * ``` */ -export function bind(): MultibindingBuilder { - return new MultibindingBuilder((c) => c); +export function withInternal< + S, + I extends PartialContainer, + const Mbs extends readonly Multibinding[], +>( + internal: I, + ...bindings: Mbs +): Multibinding, keyof ServicesOf> & InternalDeps> { + return ((core: Container) => { + const merged = core.provides(internal) as Container; + return bindings.reduce>( + (c, mb) => (mb as (c: Container) => Container)(c), + merged + ); + }) as Multibinding, keyof ServicesOf> & InternalDeps>; } /** @@ -203,7 +207,7 @@ export function bind(): MultibindingBuilder { * * @example * ```ts - * const app = compose(core, authBinding, loggingBinding, metricsBinding); + * const app = compose(core, authBinding, httpBinding, metricsBinding); * ``` */ export function compose[]>( diff --git a/src/__tests__/Multibinding.spec.ts b/src/__tests__/Multibinding.spec.ts index 2158b1f..4cf6400 100644 --- a/src/__tests__/Multibinding.spec.ts +++ b/src/__tests__/Multibinding.spec.ts @@ -2,7 +2,7 @@ import { Container } from "../Container"; import { Injectable } from "../Injectable"; import { PartialContainer } from "../PartialContainer"; -import { bind, compose } from "../Multibinding"; +import { combine, compose, multibindings, withInternal } from "../Multibinding"; import type { Multibinding } from "../Multibinding"; interface Plugin { @@ -15,32 +15,31 @@ type Registry = { middlewares: ((req: string) => string)[]; }; +const m = multibindings(); + describe("Multibinding", () => { - describe("bind().contributeValue", () => { + describe("multibindings(...).contribute(token, value)", () => { test("appends a literal value to a registry token", () => { const inline: Plugin = { name: "inline", run: () => "inline" }; - const binding = bind().contributeValue("plugins", inline).build(); + const binding = m.contribute("plugins", inline); const core = Container.providesValue("plugins", [] as Plugin[]); - const result = compose(core, binding); - - expect(result.get("plugins")).toEqual([inline]); + expect(compose(core, binding).get("plugins")).toEqual([inline]); }); - test("multiple contributeValue calls preserve insertion order", () => { + test("compose preserves the order in which bindings are passed", () => { const a: Plugin = { name: "a", run: () => "a" }; const b: Plugin = { name: "b", run: () => "b" }; - const binding = bind() - .contributeValue("plugins", a) - .contributeValue("plugins", b) - .build(); const core = Container.providesValue("plugins", [] as Plugin[]); - expect(compose(core, binding).get("plugins")).toEqual([a, b]); + expect(compose(core, m.contribute("plugins", a), m.contribute("plugins", b)).get("plugins")).toEqual([ + a, + b, + ]); }); }); - describe("bind().contributeClass", () => { + describe("multibindings(...).contribute(token, class)", () => { test("instantiates the class with deps resolved from the core container", () => { class AuthPlugin implements Plugin { static dependencies = ["apiKey"] as const; @@ -51,14 +50,13 @@ describe("Multibinding", () => { } } - const binding = bind().contributeClass("plugins", AuthPlugin).build(); + const binding = m.contribute("plugins", AuthPlugin); const core = Container.providesValue("apiKey", "secret").providesValue("plugins", [] as Plugin[]); - const result = compose(core, binding); - expect(result.get("plugins").map((p) => p.run())).toEqual(["auth:secret"]); + expect(compose(core, binding).get("plugins").map((p) => p.run())).toEqual(["auth:secret"]); }); - test("compose reports missing deps as a type error", () => { + test("compose reports missing class deps as a type error", () => { class NeedsConfig implements Plugin { static dependencies = ["config"] as const; readonly name = "needs-config"; @@ -68,7 +66,7 @@ describe("Multibinding", () => { } } - const binding = bind().contributeClass("plugins", NeedsConfig).build(); + const binding = m.contribute("plugins", NeedsConfig); const core = Container.providesValue("plugins", [] as Plugin[]); // @ts-expect-error: core does not provide "config" @@ -76,7 +74,7 @@ describe("Multibinding", () => { }); }); - describe("bind().contribute(Injectable)", () => { + describe("multibindings(...).contribute(injectable)", () => { test("appends a pre-built InjectableFunction, resolving its deps from the core", () => { const metricsPlugin = Injectable( "plugins", @@ -84,7 +82,7 @@ describe("Multibinding", () => { (prefix: string): Plugin => ({ name: "metrics", run: () => `${prefix}.requests` }) ); - const binding = bind().contribute(metricsPlugin).build(); + const binding = m.contribute(metricsPlugin); const core = Container.providesValue("statsPrefix", "app").providesValue("plugins", [] as Plugin[]); expect(compose(core, binding).get("plugins").map((p) => p.run())).toEqual(["app.requests"]); @@ -92,14 +90,63 @@ describe("Multibinding", () => { test("zero-dep Injectable", () => { const ping = Injectable("plugins", (): Plugin => ({ name: "ping", run: () => "pong" })); - const binding = bind().contribute(ping).build(); const core = Container.providesValue("plugins", [] as Plugin[]); - expect(compose(core, binding).get("plugins").map((p) => p.run())).toEqual(["pong"]); + expect(compose(core, m.contribute(ping)).get("plugins").map((p) => p.run())).toEqual(["pong"]); }); }); - describe("bind().withInternal", () => { + describe("combine", () => { + test("bundles several Multibindings into one, applied in order", () => { + const inline: Plugin = { name: "inline", run: () => "inline" }; + + class Logging implements Plugin { + static dependencies = ["label"] as const; + readonly name = "logging"; + constructor(private label: string) {} + run() { + return `log:${this.label}`; + } + } + + const bundle = combine(m.contribute("plugins", inline), m.contribute("plugins", Logging)); + + const core = Container.providesValue("label", "dev").providesValue("plugins", [] as Plugin[]); + expect(compose(core, bundle).get("plugins").map((p) => p.run())).toEqual(["inline", "log:dev"]); + }); + + test("combine() with no bindings is the identity", () => { + const core = Container.providesValue("plugins", [] as Plugin[]); + expect(compose(core, combine()).get("plugins")).toEqual([]); + }); + + test("unions deps across bundled bindings", () => { + class A implements Plugin { + static dependencies = ["depA"] as const; + readonly name = "a"; + constructor(private d: string) {} + run() { + return this.d; + } + } + class B implements Plugin { + static dependencies = ["depB"] as const; + readonly name = "b"; + constructor(private d: string) {} + run() { + return this.d; + } + } + + const bundle = combine(m.contribute("plugins", A), m.contribute("plugins", B)); + const core = Container.providesValue("depA", "x").providesValue("plugins", [] as Plugin[]); + + // @ts-expect-error: bundle requires both depA and depB; core only has depA + compose(core, bundle); + }); + }); + + describe("withInternal", () => { test("subtracts services provided by the partial from the required deps", () => { class HttpPlugin implements Plugin { static dependencies = ["retryPolicy", "endpoint"] as const; @@ -116,9 +163,7 @@ describe("Multibinding", () => { (n: number) => ({ tries: n }) ); - // `retryPolicy` is satisfied by the internal partial; `endpoint` and `maxRetries` - // must come from the core container. - const binding = bind().withInternal(internal).contributeClass("plugins", HttpPlugin).build(); + const binding = withInternal(internal, m.contribute("plugins", HttpPlugin)); const core = Container.providesValue("endpoint", "https://api.example.com") .providesValue("maxRetries", 3) .providesValue("plugins", [] as Plugin[]); @@ -128,6 +173,28 @@ describe("Multibinding", () => { ]); }); + test("internal services are visible to every binding inside the call, regardless of order", () => { + class UsesRetry implements Plugin { + static dependencies = ["retryPolicy"] as const; + readonly name = "uses-retry"; + constructor(private retry: { tries: number }) {} + run() { + return `tries:${this.retry.tries}`; + } + } + + const internal = new PartialContainer({}).provides("retryPolicy", () => ({ tries: 5 })); + + const binding = withInternal( + internal, + m.contribute("plugins", UsesRetry), + m.contribute("plugins", UsesRetry) + ); + + const core = Container.providesValue("plugins", [] as Plugin[]); + expect(compose(core, binding).get("plugins").map((p) => p.run())).toEqual(["tries:5", "tries:5"]); + }); + test("compose still flags unresolved internal dependencies", () => { class HttpPlugin implements Plugin { static dependencies = ["retryPolicy"] as const; @@ -142,7 +209,7 @@ describe("Multibinding", () => { ["maxRetries"] as const, (n: number) => ({ tries: n }) ); - const binding = bind().withInternal(internal).contributeClass("plugins", HttpPlugin).build(); + const binding = withInternal(internal, m.contribute("plugins", HttpPlugin)); const core = Container.providesValue("plugins", [] as Plugin[]); // @ts-expect-error: core is missing "maxRetries", which the internal partial needs @@ -150,38 +217,44 @@ describe("Multibinding", () => { }); }); - describe("compose", () => { - test("applies multibindings left-to-right against a shared registry", () => { - const inline: Plugin = { name: "inline", run: () => "inline" }; - const first = bind().contributeValue("plugins", inline).build(); + describe("multibindings(registry) — runtime arg form", () => { + test("binds S from a real Container without an explicit type argument", () => { + const registry = Container.providesValue("plugins", [] as Plugin[]).providesValue( + "middlewares", + [] as ((req: string) => string)[] + ); + const m2 = multibindings(registry); - class Logging implements Plugin { - static dependencies = ["label"] as const; - readonly name = "logging"; - constructor(private label: string) {} + const inline: Plugin = { name: "inline", run: () => "inline" }; + class WithDep implements Plugin { + static dependencies = ["token"] as const; + readonly name = "wd"; + constructor(private t: string) {} run() { - return `log:${this.label}`; + return this.t; } } - const second = bind().contributeClass("plugins", Logging).build(); + const fromFactory = Injectable("plugins", (): Plugin => ({ name: "factory", run: () => "factory" })); - const core = Container.providesValue("label", "dev").providesValue("plugins", [] as Plugin[]); - const result = compose(core, first, second); + const bundle = combine( + m2.contribute("plugins", inline), + m2.contribute("plugins", WithDep), + m2.contribute(fromFactory) + ); - expect(result.get("plugins").map((p) => p.run())).toEqual(["inline", "log:dev"]); + const core = registry.providesValue("token", "tok"); + expect(compose(core, bundle).get("plugins").map((p) => p.name)).toEqual(["inline", "wd", "factory"]); }); + }); + describe("compose", () => { test("returns a Container of the original Core type — internal services do not leak into types", () => { const internal = new PartialContainer({}).providesValue("secret", "hidden"); - const binding = bind() - .withInternal(internal) - .contributeValue("plugins", { name: "x", run: () => "x" }) - .build(); + const binding = withInternal(internal, m.contribute("plugins", { name: "x", run: () => "x" })); const core = Container.providesValue("plugins", [] as Plugin[]); const result = compose(core, binding); - // `secret` is reachable at runtime via PartialContainer's merge, but not in the type. // @ts-expect-error: "secret" is not part of Core's service surface result.get("secret"); }); @@ -190,22 +263,17 @@ describe("Multibinding", () => { const upper: (req: string) => string = (r) => r.toUpperCase(); const trim: (req: string) => string = (r) => r.trim(); - const pluginBinding = bind() - .contributeValue("plugins", { name: "noop", run: () => "noop" }) - .build(); - const mwBinding = bind() - .contributeValue("middlewares", upper) - .contributeValue("middlewares", trim) - .build(); - const core = Container.providesValue("plugins", [] as Plugin[]).providesValue( "middlewares", [] as ((req: string) => string)[] ); - const result = compose(core, pluginBinding, mwBinding); + const pluginBinding = m.contribute("plugins", { name: "noop", run: () => "noop" }); + const mwBindings = combine(m.contribute("middlewares", upper), m.contribute("middlewares", trim)); + + const result = compose(core, pluginBinding, mwBindings); expect(result.get("plugins")).toHaveLength(1); - expect(result.get("middlewares").map((m) => m(" hi "))).toEqual([" HI ", "hi"]); + expect(result.get("middlewares").map((mw) => mw(" hi "))).toEqual([" HI ", "hi"]); }); test("the same binding can be applied to multiple cores", () => { @@ -217,9 +285,7 @@ describe("Multibinding", () => { return `${this.base + 1}`; } } - const binding: Multibinding = bind() - .contributeClass("plugins", Counter) - .build(); + const binding = m.contribute("plugins", Counter); const coreA = Container.providesValue("base", 10).providesValue("plugins", [] as Plugin[]); const coreB = Container.providesValue("base", 100).providesValue("plugins", [] as Plugin[]); @@ -230,15 +296,30 @@ describe("Multibinding", () => { }); describe("type-level guards", () => { - test("contributeValue rejects non-array tokens", () => { + test("contribute rejects non-array tokens", () => { type Bad = { single: Plugin }; + const bad = multibindings(); // @ts-expect-error: "single" is not an array-typed token - bind().contributeValue("single", { name: "x", run: () => "x" }); + bad.contribute("single", { name: "x", run: () => "x" }); }); - test("contributeValue rejects values whose type does not match the array element", () => { + test("contribute rejects values whose type does not match the array element", () => { // @ts-expect-error: number is not assignable to Plugin - bind().contributeValue("plugins", 42); + m.contribute("plugins", 42); + }); + + test("Multibinding can be written down explicitly", () => { + class WithBase implements Plugin { + static dependencies = ["base"] as const; + readonly name = "with-base"; + constructor(private b: number) {} + run() { + return `${this.b}`; + } + } + // The annotation should not widen D beyond the binding's actual deps. + const binding: Multibinding = m.contribute("plugins", WithBase); + expect(typeof binding).toBe("function"); }); }); }); diff --git a/src/index.ts b/src/index.ts index 47929dc..054ddd5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ export { CONTAINER, Container } from "./Container"; export { Injectable, InjectableCompat, ConcatInjectable } from "./Injectable"; export { PartialContainer } from "./PartialContainer"; -export { bind, compose, MultibindingBuilder } from "./Multibinding"; -export type { Multibinding } from "./Multibinding"; +export { multibindings, combine, withInternal, compose } from "./Multibinding"; +export type { Multibinding, MultibindingFactory } from "./Multibinding"; export { InjectableFunction, InjectableClass, ServicesFromInjectables } from "./types"; From 666886815f289cb1ec80801f05b44b8823875e99 Mon Sep 17 00:00:00 2001 From: Konstantin Burov Date: Thu, 18 Jun 2026 13:53:30 +1000 Subject: [PATCH 3/3] Apply prettier formatting to multibindings additions Drives the styleguide CI step to green; no functional changes. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 33 ++++++++----- src/Multibinding.ts | 52 +++++++++----------- src/__tests__/Multibinding.spec.ts | 76 ++++++++++++++++++------------ 3 files changed, 89 insertions(+), 72 deletions(-) diff --git a/README.md b/README.md index 38563c3..0a04cf5 100644 --- a/README.md +++ b/README.md @@ -126,11 +126,15 @@ A typical layout: // registry.ts — one place declares the shape of the extension points. import { Container, multibindings } from "@snap/ts-inject"; -export interface Plugin { name: string; run(): void } +export interface Plugin { + name: string; + run(): void; +} -export const registry = Container - .providesValue("plugins", [] as Plugin[]) - .providesValue("middlewares", [] as ((req: Request) => Request)[]); +export const registry = Container.providesValue("plugins", [] as Plugin[]).providesValue( + "middlewares", + [] as ((req: Request) => Request)[] +); // Shared factory bound to the registry shape — modules import this. export const m = multibindings(registry); @@ -144,7 +148,9 @@ class AuthPlugin { static dependencies = ["apiKey"] as const; readonly name = "auth"; constructor(private apiKey: string) {} - run() { /* ... */ } + run() { + /* ... */ + } } export const authBinding = m.contribute("plugins", AuthPlugin); // requires `apiKey` from core @@ -196,7 +202,7 @@ import { m } from "../registry"; export const authBindings = combine( m.contribute("plugins", AuthPlugin), m.contribute("plugins", OAuthPlugin), - m.contribute(authMetricsInjectable), + m.contribute(authMetricsInjectable) ); ``` @@ -208,7 +214,7 @@ A binding's contribution often needs a helper that isn't a registry entry — sa 1. **Hard-code it** (`new RetryPolicy(3)` in the constructor). No DI, no config, no test seam. 2. **Add `retryPolicy` to the core container.** Now every other binding can see it, the core's service type lists it, and you've leaked one plugin's implementation detail into the global namespace. -3. **`withInternal`.** Attach a small `PartialContainer` of helpers that *only this binding's contributions* can see. The helper is properly DI-wired, but invisible to other bindings and to consumers of the composed container. +3. **`withInternal`.** Attach a small `PartialContainer` of helpers that _only this binding's contributions_ can see. The helper is properly DI-wired, but invisible to other bindings and to consumers of the composed container. ```ts import { PartialContainer, withInternal } from "@snap/ts-inject"; @@ -223,13 +229,16 @@ const retryInternal = new PartialContainer({}).provides( class HttpPlugin { static dependencies = ["retryPolicy", "endpoint"] as const; readonly name = "http"; - constructor(private retry: RetryPolicy, private endpoint: string) {} - run() { /* ... */ } + constructor( + private retry: RetryPolicy, + private endpoint: string + ) {} + run() { + /* ... */ + } } -export const httpBinding = withInternal(retryInternal, - m.contribute("plugins", HttpPlugin), -); +export const httpBinding = withInternal(retryInternal, m.contribute("plugins", HttpPlugin)); ``` **Dep-flow rule:** a binding requires whatever its contributions declare as deps, **minus** what `withInternal` provides, **plus** what `withInternal` itself depends on but doesn't provide. Above: diff --git a/src/Multibinding.ts b/src/Multibinding.ts index 0014918..5a49bd5 100644 --- a/src/Multibinding.ts +++ b/src/Multibinding.ts @@ -11,11 +11,12 @@ type DepsForClass = C extends { readonly dependencies: readonly (infer K exte ? Record : {}; -type DepsForInjectable = F extends InjectableFunction - ? Tokens extends readonly (infer K extends string)[] - ? Record - : {} - : {}; +type DepsForInjectable = + F extends InjectableFunction + ? Tokens extends readonly (infer K extends string)[] + ? Record + : {} + : {}; type ServicesOf = I extends PartialContainer ? S : never; type InternalDeps = I extends PartialContainer ? D : never; @@ -45,13 +46,14 @@ export type Multibinding = ((core: Container) => Container = Mb extends Multibinding - ? unknown extends D - ? Mb - : keyof D extends keyof Core +type Validated = + Mb extends Multibinding + ? unknown extends D ? Mb - : Mb & { readonly missingDeps: Exclude } - : never; + : keyof D extends keyof Core + ? Mb + : Mb & { readonly missingDeps: Exclude } + : never; /** * Family of multibinding helpers bound to a specific registry shape `S`. @@ -70,16 +72,10 @@ export interface MultibindingFactory { * `dependencies` become the binding's required deps. */ contribute: { - < - T extends ArrayTokens, - F extends InjectableFunction>, - >( + , F extends InjectableFunction>>( injectable: F ): Multibinding>; - < - T extends ArrayTokens, - Class extends InjectableClass, readonly string[]>, - >( + , Class extends InjectableClass, readonly string[]>>( token: T, cls: Class ): Multibinding>; @@ -153,10 +149,10 @@ export function combine[] = r ...bindings: Mbs ): Multibinding> { return ((core: Container) => - bindings.reduce>( - (c, mb) => (mb as (c: Container) => Container)(c), - core - )) as Multibinding>; + bindings.reduce>((c, mb) => (mb as (c: Container) => Container)(c), core)) as Multibinding< + S, + UnionDeps + >; } /** @@ -187,16 +183,10 @@ export function withInternal< S, I extends PartialContainer, const Mbs extends readonly Multibinding[], ->( - internal: I, - ...bindings: Mbs -): Multibinding, keyof ServicesOf> & InternalDeps> { +>(internal: I, ...bindings: Mbs): Multibinding, keyof ServicesOf> & InternalDeps> { return ((core: Container) => { const merged = core.provides(internal) as Container; - return bindings.reduce>( - (c, mb) => (mb as (c: Container) => Container)(c), - merged - ); + return bindings.reduce>((c, mb) => (mb as (c: Container) => Container)(c), merged); }) as Multibinding, keyof ServicesOf> & InternalDeps>; } diff --git a/src/__tests__/Multibinding.spec.ts b/src/__tests__/Multibinding.spec.ts index 4cf6400..0f16583 100644 --- a/src/__tests__/Multibinding.spec.ts +++ b/src/__tests__/Multibinding.spec.ts @@ -32,10 +32,7 @@ describe("Multibinding", () => { const b: Plugin = { name: "b", run: () => "b" }; const core = Container.providesValue("plugins", [] as Plugin[]); - expect(compose(core, m.contribute("plugins", a), m.contribute("plugins", b)).get("plugins")).toEqual([ - a, - b, - ]); + expect(compose(core, m.contribute("plugins", a), m.contribute("plugins", b)).get("plugins")).toEqual([a, b]); }); }); @@ -53,7 +50,11 @@ describe("Multibinding", () => { const binding = m.contribute("plugins", AuthPlugin); const core = Container.providesValue("apiKey", "secret").providesValue("plugins", [] as Plugin[]); - expect(compose(core, binding).get("plugins").map((p) => p.run())).toEqual(["auth:secret"]); + expect( + compose(core, binding) + .get("plugins") + .map((p) => p.run()) + ).toEqual(["auth:secret"]); }); test("compose reports missing class deps as a type error", () => { @@ -85,14 +86,22 @@ describe("Multibinding", () => { const binding = m.contribute(metricsPlugin); const core = Container.providesValue("statsPrefix", "app").providesValue("plugins", [] as Plugin[]); - expect(compose(core, binding).get("plugins").map((p) => p.run())).toEqual(["app.requests"]); + expect( + compose(core, binding) + .get("plugins") + .map((p) => p.run()) + ).toEqual(["app.requests"]); }); test("zero-dep Injectable", () => { const ping = Injectable("plugins", (): Plugin => ({ name: "ping", run: () => "pong" })); const core = Container.providesValue("plugins", [] as Plugin[]); - expect(compose(core, m.contribute(ping)).get("plugins").map((p) => p.run())).toEqual(["pong"]); + expect( + compose(core, m.contribute(ping)) + .get("plugins") + .map((p) => p.run()) + ).toEqual(["pong"]); }); }); @@ -112,7 +121,11 @@ describe("Multibinding", () => { const bundle = combine(m.contribute("plugins", inline), m.contribute("plugins", Logging)); const core = Container.providesValue("label", "dev").providesValue("plugins", [] as Plugin[]); - expect(compose(core, bundle).get("plugins").map((p) => p.run())).toEqual(["inline", "log:dev"]); + expect( + compose(core, bundle) + .get("plugins") + .map((p) => p.run()) + ).toEqual(["inline", "log:dev"]); }); test("combine() with no bindings is the identity", () => { @@ -151,26 +164,29 @@ describe("Multibinding", () => { class HttpPlugin implements Plugin { static dependencies = ["retryPolicy", "endpoint"] as const; readonly name = "http"; - constructor(private retry: { tries: number }, private endpoint: string) {} + constructor( + private retry: { tries: number }, + private endpoint: string + ) {} run() { return `${this.endpoint}#${this.retry.tries}`; } } - const internal = new PartialContainer({}).provides( - "retryPolicy", - ["maxRetries"] as const, - (n: number) => ({ tries: n }) - ); + const internal = new PartialContainer({}).provides("retryPolicy", ["maxRetries"] as const, (n: number) => ({ + tries: n, + })); const binding = withInternal(internal, m.contribute("plugins", HttpPlugin)); const core = Container.providesValue("endpoint", "https://api.example.com") .providesValue("maxRetries", 3) .providesValue("plugins", [] as Plugin[]); - expect(compose(core, binding).get("plugins").map((p) => p.run())).toEqual([ - "https://api.example.com#3", - ]); + expect( + compose(core, binding) + .get("plugins") + .map((p) => p.run()) + ).toEqual(["https://api.example.com#3"]); }); test("internal services are visible to every binding inside the call, regardless of order", () => { @@ -185,14 +201,14 @@ describe("Multibinding", () => { const internal = new PartialContainer({}).provides("retryPolicy", () => ({ tries: 5 })); - const binding = withInternal( - internal, - m.contribute("plugins", UsesRetry), - m.contribute("plugins", UsesRetry) - ); + const binding = withInternal(internal, m.contribute("plugins", UsesRetry), m.contribute("plugins", UsesRetry)); const core = Container.providesValue("plugins", [] as Plugin[]); - expect(compose(core, binding).get("plugins").map((p) => p.run())).toEqual(["tries:5", "tries:5"]); + expect( + compose(core, binding) + .get("plugins") + .map((p) => p.run()) + ).toEqual(["tries:5", "tries:5"]); }); test("compose still flags unresolved internal dependencies", () => { @@ -204,11 +220,9 @@ describe("Multibinding", () => { return `tries:${this.retry.tries}`; } } - const internal = new PartialContainer({}).provides( - "retryPolicy", - ["maxRetries"] as const, - (n: number) => ({ tries: n }) - ); + const internal = new PartialContainer({}).provides("retryPolicy", ["maxRetries"] as const, (n: number) => ({ + tries: n, + })); const binding = withInternal(internal, m.contribute("plugins", HttpPlugin)); const core = Container.providesValue("plugins", [] as Plugin[]); @@ -243,7 +257,11 @@ describe("Multibinding", () => { ); const core = registry.providesValue("token", "tok"); - expect(compose(core, bundle).get("plugins").map((p) => p.name)).toEqual(["inline", "wd", "factory"]); + expect( + compose(core, bundle) + .get("plugins") + .map((p) => p.name) + ).toEqual(["inline", "wd", "factory"]); }); });