diff --git a/README.md b/README.md index e709530..0a04cf5 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,158 @@ 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 `multibindings()` and `compose()`: + +- `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, multibindings } 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)[] +); + +// 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 { m } from "../registry"; + +class AuthPlugin { + static dependencies = ["apiKey"] as const; + readonly name = "auth"; + constructor(private apiKey: string) {} + run() { + /* ... */ + } +} + +export const authBinding = m.contribute("plugins", AuthPlugin); // requires `apiKey` from core +``` + +```ts +// metrics/binding.ts — pre-built Injectable() works the same way. +import { Injectable } from "@snap/ts-inject"; +import { m } 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 = m.contribute(metricsPlugin); +``` + +```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". + +##### 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: + +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 { PartialContainer, withInternal } from "@snap/ts-inject"; +import { m } from "../registry"; + +const retryInternal = 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 = 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"]`. +- `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. + +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 | `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 - **Container**: A registry for all services, handling their creation and retrieval. @@ -119,6 +271,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 `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 new file mode 100644 index 0000000..5a49bd5 --- /dev/null +++ b/src/Multibinding.ts @@ -0,0 +1,211 @@ +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; + +/** 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 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 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 Validated = + Mb extends Multibinding + ? unknown extends D + ? Mb + : keyof D extends keyof Core + ? Mb + : Mb & { readonly missingDeps: Exclude } + : never; + +/** + * Family of multibinding helpers bound to a specific registry shape `S`. + * + * 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 interface MultibindingFactory { + /** + * 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. + */ + contribute: { + , F extends InjectableFunction>>( + injectable: F + ): Multibinding>; + , Class extends InjectableClass, readonly string[]>>( + token: T, + cls: Class + ): Multibinding>; + >(token: T, value: ElementOf): Multibinding; + }; +} + +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; + } + 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; +} + +/** + * 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"] }; +} + +/** + * 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< + S, + UnionDeps + >; +} + +/** + * Apply contributions against a private {@link PartialContainer} of helpers that's invisible to + * other bindings and to consumers of the composed container's type. + * + * 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 + * const m = multibindings(); + * const retryInternal = new PartialContainer({}).provides( + * "retryPolicy", + * ["maxRetries"] as const, + * (n: number) => new RetryPolicy(n) + * ); + * + * // HttpPlugin.dependencies = ["retryPolicy", "endpoint"] + * // → the composed binding requires { endpoint, maxRetries } — retryPolicy is internal. + * export const httpBinding = withInternal(retryInternal, + * m.contribute("plugins", HttpPlugin), + * ); + * ``` + */ +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>; +} + +/** + * 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, httpBinding, 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..0f16583 --- /dev/null +++ b/src/__tests__/Multibinding.spec.ts @@ -0,0 +1,343 @@ +/* eslint-disable max-classes-per-file */ +import { Container } from "../Container"; +import { Injectable } from "../Injectable"; +import { PartialContainer } from "../PartialContainer"; +import { combine, compose, multibindings, withInternal } from "../Multibinding"; +import type { Multibinding } from "../Multibinding"; + +interface Plugin { + name: string; + run(): string; +} + +type Registry = { + plugins: Plugin[]; + middlewares: ((req: string) => string)[]; +}; + +const m = multibindings(); + +describe("Multibinding", () => { + describe("multibindings(...).contribute(token, value)", () => { + test("appends a literal value to a registry token", () => { + const inline: Plugin = { name: "inline", run: () => "inline" }; + const binding = m.contribute("plugins", inline); + + const core = Container.providesValue("plugins", [] as Plugin[]); + expect(compose(core, binding).get("plugins")).toEqual([inline]); + }); + + 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 core = Container.providesValue("plugins", [] as Plugin[]); + expect(compose(core, m.contribute("plugins", a), m.contribute("plugins", b)).get("plugins")).toEqual([a, b]); + }); + }); + + 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; + readonly name = "auth"; + constructor(private apiKey: string) {} + run() { + return `auth:${this.apiKey}`; + } + } + + 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"]); + }); + + test("compose reports missing class 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 = m.contribute("plugins", NeedsConfig); + const core = Container.providesValue("plugins", [] as Plugin[]); + + // @ts-expect-error: core does not provide "config" + compose(core, binding); + }); + }); + + describe("multibindings(...).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 = 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"]); + }); + + 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"]); + }); + }); + + 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; + 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, + })); + + 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"]); + }); + + 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; + 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 = 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 + compose(core, binding); + }); + }); + + 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); + + 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 this.t; + } + } + const fromFactory = Injectable("plugins", (): Plugin => ({ name: "factory", run: () => "factory" })); + + const bundle = combine( + m2.contribute("plugins", inline), + m2.contribute("plugins", WithDep), + m2.contribute(fromFactory) + ); + + 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 = withInternal(internal, m.contribute("plugins", { name: "x", run: () => "x" })); + + const core = Container.providesValue("plugins", [] as Plugin[]); + const result = compose(core, binding); + + // @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 core = Container.providesValue("plugins", [] as Plugin[]).providesValue( + "middlewares", + [] as ((req: string) => string)[] + ); + + 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((mw) => mw(" 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 = m.contribute("plugins", Counter); + + 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("contribute rejects non-array tokens", () => { + type Bad = { single: Plugin }; + const bad = multibindings(); + // @ts-expect-error: "single" is not an array-typed token + bad.contribute("single", { name: "x", run: () => "x" }); + }); + + test("contribute rejects values whose type does not match the array element", () => { + // @ts-expect-error: number is not assignable to Plugin + 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 d17449d..054ddd5 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 { multibindings, combine, withInternal, compose } from "./Multibinding"; +export type { Multibinding, MultibindingFactory } from "./Multibinding"; export { InjectableFunction, InjectableClass, ServicesFromInjectables } from "./types";