Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 153 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Registry>()` if you only have a type) returns a small factory bound to the registry shape. Its `contribute(...)` method produces a `Multibinding<Registry, Deps>` — 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.
Expand All @@ -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

Expand Down
211 changes: 211 additions & 0 deletions src/Multibinding.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
import type { Container } from "./Container";
import type { PartialContainer } from "./PartialContainer";
import type { InjectableClass, InjectableFunction } from "./types";

type ElementOf<T> = 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<S> = { [K in keyof S]: S[K] extends readonly unknown[] ? K : never }[keyof S];

type DepsForClass<C> = C extends { readonly dependencies: readonly (infer K extends string)[] }
? Record<K, unknown>
: {};

type DepsForInjectable<F> =
F extends InjectableFunction<any, infer Tokens, any, any>
? Tokens extends readonly (infer K extends string)[]
? Record<K, unknown>
: {}
: {};

type ServicesOf<I> = I extends PartialContainer<infer S, any> ? S : never;
type InternalDeps<I> = I extends PartialContainer<any, infer D> ? D : never;

/** Aggregate the phantom `D` of a tuple of Multibindings into a single dependency record. */
type UnionDeps<Mbs> = Mbs extends readonly [infer H, ...infer R]
? (H extends Multibinding<any, infer D> ? D : {}) & UnionDeps<R>
: {};

/**
* 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<S, D = {}> = ((core: Container<S & D>) => Container<S & D>) & {
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<Core, Mb> =
Mb extends Multibinding<any, infer D>
? unknown extends D
? Mb
: keyof D extends keyof Core
? Mb
: Mb & { readonly missingDeps: Exclude<keyof D & string, keyof Core & string> }
: 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<S> {
/**
* 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: {
<T extends ArrayTokens<S>, F extends InjectableFunction<any, readonly string[], T, ElementOf<S[T]>>>(
injectable: F
): Multibinding<S, DepsForInjectable<F>>;
<T extends ArrayTokens<S>, Class extends InjectableClass<any, ElementOf<S[T]>, readonly string[]>>(
token: T,
cls: Class
): Multibinding<S, DepsForClass<Class>>;
<T extends ArrayTokens<S>>(token: T, value: ElementOf<S[T]>): Multibinding<S, {}>;
};
}

function contributeImpl(first: unknown, second?: unknown): Multibinding<any, any> {
// 1-arg form: contribute(injectable). The injectable carries its own token.
if (second === undefined) {
const fn = first as InjectableFunction<any, readonly string[], string, unknown>;
return ((core: Container<any>) => core.append(fn as never)) as Multibinding<any, any>;
}
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<any, unknown, readonly string[]>;
return ((core: Container<any>) => core.appendClass(token, cls as never)) as Multibinding<any, any>;
}
// 2-arg form, value.
return ((core: Container<any>) => core.appendValue(token, second as never)) as Multibinding<any, any>;
}

/**
* 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<S>()` — 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<Registry>();
* export const inlineBinding = m.contribute("plugins", { name: "x", run: () => "x" });
* ```
*/
export function multibindings<S>(): MultibindingFactory<S>;
export function multibindings<S>(registry: Container<S>): MultibindingFactory<S>;
export function multibindings<S>(_registry?: Container<S>): MultibindingFactory<S> {
return { contribute: contributeImpl as MultibindingFactory<S>["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<Registry>();
*
* export const authBindings = combine(
* m.contribute("plugins", AuthPlugin),
* m.contribute("plugins", OAuthPlugin),
* m.contribute(authMetricsInjectable),
* );
* ```
*/
export function combine<S, const Mbs extends readonly Multibinding<S, any>[] = readonly []>(
...bindings: Mbs
): Multibinding<S, UnionDeps<Mbs>> {
return ((core: Container<any>) =>
bindings.reduce<Container<any>>((c, mb) => (mb as (c: Container<any>) => Container<any>)(c), core)) as Multibinding<
S,
UnionDeps<Mbs>
>;
}

/**
* 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<Registry>();
* 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<any, any>,
const Mbs extends readonly Multibinding<S, any>[],
>(internal: I, ...bindings: Mbs): Multibinding<S, Omit<UnionDeps<Mbs>, keyof ServicesOf<I>> & InternalDeps<I>> {
return ((core: Container<any>) => {
const merged = core.provides(internal) as Container<any>;
return bindings.reduce<Container<any>>((c, mb) => (mb as (c: Container<any>) => Container<any>)(c), merged);
}) as Multibinding<S, Omit<UnionDeps<Mbs>, keyof ServicesOf<I>> & InternalDeps<I>>;
}

/**
* 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, const Mbs extends readonly Multibinding<any, any>[]>(
core: Container<Core>,
...bindings: { [I in keyof Mbs]: Validated<Core, Mbs[I]> }
): Container<Core> {
return (bindings as readonly Multibinding<any, any>[]).reduce<Container<any>>(
(c, mb) => (mb as (c: Container<any>) => Container<any>)(c),
core
) as Container<Core>;
}
Loading
Loading