From cb5904838fd7f1da7c4aecff1debeabdb65d57c5 Mon Sep 17 00:00:00 2001 From: Konstantin Burov Date: Thu, 5 Mar 2026 15:20:17 +1100 Subject: [PATCH 1/9] Add inline (token, factory) and (token, deps, factory) overloads to provides() Support provides('token', () => value) and provides('token', ['dep'], (d) => ...) as alternatives to provides(Injectable('token', ...)), reducing boilerplate for common service registration patterns. Co-Authored-By: Claude Opus 4.6 --- src/Container.ts | 102 ++++++++++++++++++++++++++------ src/__tests__/Container.spec.ts | 45 ++++++++++++++ 2 files changed, 129 insertions(+), 18 deletions(-) diff --git a/src/Container.ts b/src/Container.ts index 21ae70e..bb07100 100644 --- a/src/Container.ts +++ b/src/Container.ts @@ -1,7 +1,15 @@ import type { Memoized } from "./memoize"; import { isMemoized, memoize } from "./memoize"; import { PartialContainer } from "./PartialContainer"; -import type { AddService, AddServices, InjectableClass, InjectableFunction, TokenType, ValidTokens } from "./types"; +import type { + AddService, + AddServices, + CorrespondingServices, + InjectableClass, + InjectableFunction, + TokenType, + ValidTokens, +} from "./types"; import { ClassInjectable, ConcatInjectable, Injectable } from "./Injectable"; import { entries } from "./entries"; @@ -82,15 +90,28 @@ export class Container { fn: InjectableFunction<{}, [], Token, Service> ): Container>; - static provides( - fnOrContainer: InjectableFunction<{}, [], TokenType, any> | PartialContainer | Container - ): Container { + /** + * Creates a new [Container] by providing a Service via a zero-argument factory function. + * The factory is called lazily on first retrieval and the result is memoized. + * + * @example + * ```ts + * const container = Container.provides('Logger', () => new Logger()); + * ``` + */ + static provides( + token: Token, + fn: () => Service + ): Container>; + + static provides(first: any, second?: any): Container { + if (typeof second === "function") return new Container({}).provides(first, second); // Although the `provides` method has overloads that match both members of the union type separately, it does // not match the union type itself, so the compiler forces us to branch and handle each type within the union // separately. (Maybe in the future the compiler will decide to infer this, but for now this is necessary.) - if (fnOrContainer instanceof PartialContainer) return new Container({}).provides(fnOrContainer); - if (fnOrContainer instanceof Container) return new Container({}).provides(fnOrContainer); - return new Container({}).provides(fnOrContainer); + if (first instanceof PartialContainer) return new Container({}).provides(first); + if (first instanceof Container) return new Container({}).provides(first); + return new Container({}).provides(first); } /** @@ -389,24 +410,69 @@ export class Container { fn: InjectableFunction ): Container>; - provides[], Service, AdditionalServices>( - fnOrContainer: - | InjectableFunction - | PartialContainer - | Container - ): Container { - if (fnOrContainer instanceof PartialContainer || fnOrContainer instanceof Container) { - const factories = - fnOrContainer instanceof PartialContainer ? fnOrContainer.getFactories(this) : fnOrContainer.factories; + /** + * Registers a new service using a zero-argument factory function. + * The factory is called lazily on first retrieval and the result is memoized. + * + * @example + * ```ts + * const container = Container + * .providesValue('config', { port: 3000 }) + * .provides('Logger', () => new Logger()) + * ``` + * + * @param token A unique Token identifying the service. + * @param fn A zero-argument factory function that creates the service. + * @returns A new Container with the service registered. + */ + provides( + token: Token, + fn: () => Service + ): Container>; + + /** + * Registers a new service using a factory function with dependencies. + * Dependencies are specified as tokens and resolved from the container when the factory is called. + * + * @example + * ```ts + * const container = Container + * .providesValue('config', { port: 3000 }) + * .provides('Server', ['config'] as const, (config: Config) => new Server(config)) + * ``` + * + * @param token A unique Token identifying the service. + * @param dependencies A readonly array of tokens for the factory's dependencies. + * @param fn A factory function whose parameters match the resolved dependency types. + * @returns A new Container with the service registered. + */ + provides[], Service>( + token: Token, + dependencies: Tokens, + fn: (...args: CorrespondingServices extends infer T extends readonly any[] ? T : never) => Service + ): Container>; + + provides(first: any, second?: any, third?: any): Container { + // Two-arg form: provides(token, factory) + if (typeof second === "function") { + return this.providesService(Injectable(first, second)); + } + // Three-arg form: provides(token, dependencies, factory) + if (Array.isArray(second) && typeof third === "function") { + return this.providesService(Injectable(first, second, third) as any); + } + // Original single-arg forms + if (first instanceof PartialContainer || first instanceof Container) { + const factories = first instanceof PartialContainer ? first.getFactories(this) : first.factories; // Safety: `this.factories` and `factories` are both properly type checked, so merging them produces // a Factories object with keys from both Services and AdditionalServices. The compiler is unable to // infer that Factories & Factories == Factories, so the cast is required. return new Container({ ...this.factories, ...factories, - } as unknown as MaybeMemoizedFactories>); + } as unknown as MaybeMemoizedFactories>); } - return this.providesService(fnOrContainer); + return this.providesService(first); } /** diff --git a/src/__tests__/Container.spec.ts b/src/__tests__/Container.spec.ts index 0a67495..ea060e4 100644 --- a/src/__tests__/Container.spec.ts +++ b/src/__tests__/Container.spec.ts @@ -97,6 +97,51 @@ describe("Container", () => { }); }); + describe("when providing a Service using inline token and factory", () => { + test("provides a zero-dep service via provides(token, factory)", () => { + const containerWithService = Container.provides("TestService", () => "testService"); + expect(containerWithService.get("TestService")).toBe("testService"); + }); + + test("provides a zero-dep service on an existing container", () => { + const containerWithService = container.providesValue("value", 1).provides("TestService", () => "testService"); + expect(containerWithService.get("TestService")).toBe("testService"); + expect(containerWithService.get("value")).toBe(1); + }); + + test("provides a service with dependencies via provides(token, deps, factory)", () => { + const containerWithService = Container.providesValue("dep", 42).provides( + "TestService", + ["dep"] as const, + (dep: number) => `value is ${dep}` + ); + expect(containerWithService.get("TestService")).toBe("value is 42"); + }); + + test("the factory is lazily called and memoized", () => { + const factory = jest.fn(() => "testService"); + const containerWithService = Container.provides("TestService", factory); + expect(factory).not.toHaveBeenCalled(); + containerWithService.get("TestService"); + containerWithService.get("TestService"); + expect(factory).toHaveBeenCalledTimes(1); + }); + + test("type error when dependency token does not exist", () => { + // @ts-expect-error 'missing' is not a valid token + Container.providesValue("dep", 1).provides("service", ["missing"] as const, (x: any) => x); + }); + + test("type error when factory param type does not match dependency", () => { + Container.providesValue("dep", 42).provides( + "service", + ["dep"] as const, + // @ts-expect-error dep is number, not string + (dep: string) => dep + ); + }); + }); + describe("when providing a Service with dependencies", () => { let dependency: InjectableFunction; let injectable: InjectableFunction<{ TestDependency: string }, readonly ["TestDependency"], "TestService", string>; From 68e120adcfe2e809a686eded0165a3e48d9d0e9e Mon Sep 17 00:00:00 2001 From: Konstantin Burov Date: Thu, 5 Mar 2026 15:50:49 +1100 Subject: [PATCH 2/9] Add inline (token, factory) overloads to PartialContainer.provides() and Container.append() Support provides('token', () => value) and provides('token', ['dep'], (d) => ...) on PartialContainer, and append('token', () => value) / append('token', ['dep'], fn) on Container, reducing Injectable() boilerplate across more API surfaces. Co-Authored-By: Claude Opus 4.6 --- src/Container.ts | 87 ++++++++++++++++++++++++-- src/PartialContainer.ts | 50 ++++++++++++++- src/__tests__/Container.spec.ts | 14 +++++ src/__tests__/PartialContainer.spec.ts | 27 ++++++++ 4 files changed, 170 insertions(+), 8 deletions(-) diff --git a/src/Container.ts b/src/Container.ts index bb07100..62c51cf 100644 --- a/src/Container.ts +++ b/src/Container.ts @@ -572,16 +572,91 @@ export class Container { * @returns The updated Container, now including the new service instance appended to the array * specified by the token. */ - append = < + /** + * Appends a new service instance to an existing array within the container using a zero-argument factory function. + * + * @example + * ```ts + * const container = Container.fromObject({ services: [] as Service[] }); + * const newContainer = container.append('services', () => new Service()); + * ``` + * + * @param token A unique Token corresponding to the previously defined typed array. + * @param fn A zero-argument factory function that returns the service to append. + * @returns The updated Container with the new service instance appended. + */ + append>( + token: Token, + fn: () => Service + ): Container; + + /** + * Appends a new service instance to an existing array within the container using a factory function + * with dependencies. + * + * @example + * ```ts + * const container = Container + * .providesValue('config', { url: '...' }) + * .providesValue('services', [] as Service[]) + * .append('services', ['config'] as const, (config: Config) => new Service(config)); + * ``` + * + * @param token A unique Token corresponding to the previously defined typed array. + * @param dependencies A readonly array of tokens for the factory's dependencies. + * @param fn A factory function whose parameters match the resolved dependency types. + * @returns The updated Container with the new service instance appended. + */ + append< Token extends keyof Services, - Tokens extends readonly ValidTokens[], + const Tokens extends readonly ValidTokens[], Service extends ArrayElement, >( - fn: InjectableFunction - ): Container => - this.providesService( - ConcatInjectable(fn.token, () => this.providesService(fn).get(fn.token)) + token: Token, + dependencies: Tokens, + fn: (...args: CorrespondingServices extends infer T extends readonly any[] ? T : never) => Service + ): Container; + + /** + * Appends a new service instance to an existing array within the container using an `InjectableFunction`. + * + * @example + * ```ts + * const container = Container.fromObject({ services: [] as Service[] }); + * const newContainer = container.append(Injectable('services', () => new Service())); + * console.log(newContainer.get('services').length); // prints 1; + * ``` + * + * @param fn - An injectable function that returns the Service. + * @returns The updated Container, now including the new service instance appended to the array + * specified by the token. + */ + append< + Token extends keyof Services, + Tokens extends readonly ValidTokens[], + Service extends ArrayElement, + >(fn: InjectableFunction): Container; + + append(first: any, second?: any, third?: any): Container { + let token: any; + let fn: any; + if (typeof second === "function") { + // Two-arg form: append(token, factory) + token = first; + fn = Injectable(first as string, second); + } else if (Array.isArray(second) && typeof third === "function") { + // Three-arg form: append(token, dependencies, factory) + token = first; + fn = Injectable(first as string, second, third); + } else { + // Original single-arg form + token = first.token; + fn = first; + } + return this.providesService( + ConcatInjectable(token, () => this.providesService(fn as any).get(token)) ) as Container; + } private providesService< Token extends TokenType, diff --git a/src/PartialContainer.ts b/src/PartialContainer.ts index 4b85aa3..3e5fe2b 100644 --- a/src/PartialContainer.ts +++ b/src/PartialContainer.ts @@ -108,8 +108,54 @@ export class PartialContainer { AddDependencies, ServicesFromTokenizedParams>, keyof Services > - > { - return new PartialContainer({ ...this.injectables, [fn.token]: fn } as any); + >; + + /** + * Create a new PartialContainer which provides a Service created by a zero-argument factory function. + * + * @param token A unique Token identifying the service. + * @param fn A zero-argument factory function that creates the service. + */ + provides( + token: Token, + fn: () => Service + ): PartialContainer, ExcludeKey>; + + /** + * Create a new PartialContainer which provides a Service created by a factory function with dependencies. + * Dependencies that are not already provided by this PartialContainer will be tracked and must be + * fulfilled by the Container this PartialContainer is eventually provided to. + * + * @param token A unique Token identifying the service. + * @param dependencies A readonly array of tokens for the factory's dependencies. + * @param fn A factory function whose parameters match the dependencies. + */ + provides( + token: Token, + dependencies: Tokens, + fn: (...args: Tokens["length"] extends Params["length"] ? Params : void[]) => Service + ): Tokens["length"] extends Params["length"] + ? PartialContainer< + AddService, + ExcludeKey< + AddDependencies, ServicesFromTokenizedParams>, + keyof Services + > + > + : never; + + provides(first: any, second?: any, third?: any): PartialContainer { + // Two-arg form: provides(token, factory) + if (typeof second === "function") { + return new PartialContainer({ ...this.injectables, [first]: Injectable(first, second) } as any); + } + // Three-arg form: provides(token, dependencies, factory) + if (Array.isArray(second) && typeof third === "function") { + const fn = Injectable(first, second, third); + return new PartialContainer({ ...this.injectables, [first]: fn } as any); + } + // Original single-arg form + return new PartialContainer({ ...this.injectables, [first.token]: first } as any); } /** diff --git a/src/__tests__/Container.spec.ts b/src/__tests__/Container.spec.ts index ea060e4..973bd48 100644 --- a/src/__tests__/Container.spec.ts +++ b/src/__tests__/Container.spec.ts @@ -310,6 +310,20 @@ describe("Container", () => { expect(container.get("service")).toEqual([1, 2, 3]); }); + test("appends zero-dep factory via append(token, factory)", () => { + const container = Container.providesValue("service", [] as number[]) + .append("service", () => 1) + .append("service", () => 2); + expect(container.get("service")).toEqual([1, 2]); + }); + + test("appends factory with dependencies via append(token, deps, factory)", () => { + const container = Container.providesValue("value", 10) + .providesValue("service", [] as number[]) + .append("service", ["value"] as const, (value: number) => value * 2); + expect(container.get("service")).toEqual([20]); + }); + test("errors when the token is not registered", () => { // @ts-expect-error new Container({}).appendValue("service", 1); diff --git a/src/__tests__/PartialContainer.spec.ts b/src/__tests__/PartialContainer.spec.ts index 9780d12..e637c25 100644 --- a/src/__tests__/PartialContainer.spec.ts +++ b/src/__tests__/PartialContainer.spec.ts @@ -95,6 +95,33 @@ describe("PartialContainer", () => { }); }); + describe("when providing a Service using inline token and factory", () => { + test("provides a zero-dep service via provides(token, factory)", () => { + const partial = new PartialContainer({}).provides("TestService", () => "testService"); + const combined = Container.provides(partial); + expect(combined.get("TestService")).toBe("testService"); + }); + + test("provides a service with dependencies via provides(token, deps, factory)", () => { + const partial = new PartialContainer({}).provides( + "TestService", + ["dep"] as const, + (dep: string) => `value is ${dep}` + ); + const combined = Container.providesValue("dep", "hello").provides(partial); + expect(combined.get("TestService")).toBe("value is hello"); + }); + + test("tracks unresolved dependencies from inline factory", () => { + const partial = new PartialContainer({}).provides("TestService", ["dep"] as const, (dep: string) => dep); + // @ts-expect-error should fail because 'dep' is not provided + Container.provides(partial); + + // succeeds when dependency is provided + Container.providesValue("dep", "hello").provides(partial); + }); + }); + describe("when providing a Service using the same Token as an existing Service", () => { describe("provided by the PartialContainer", () => { describe("and the new Service does not depend on the old Service", () => { From 2aa972a606844c25ccbd858118d915e719458637 Mon Sep 17 00:00:00 2001 From: Konstantin Burov Date: Thu, 5 Mar 2026 16:07:46 +1100 Subject: [PATCH 3/9] Add provides(PartialContainer), provides(Container), and fromObject to PartialContainer Enable composing PartialContainers by merging in other PartialContainers or Containers. Dependencies are correctly tracked: the other container's services satisfy existing dependencies, and unresolved ones propagate. Add static fromObject() factory for bootstrapping from plain objects. Co-Authored-By: Claude Opus 4.6 --- src/PartialContainer.ts | 66 ++++++++++++++++++++++- src/__tests__/PartialContainer.spec.ts | 73 ++++++++++++++++++++++++++ 2 files changed, 138 insertions(+), 1 deletion(-) diff --git a/src/PartialContainer.ts b/src/PartialContainer.ts index 3e5fe2b..685e934 100644 --- a/src/PartialContainer.ts +++ b/src/PartialContainer.ts @@ -4,6 +4,7 @@ import { memoize } from "./memoize"; import type { Container } from "./Container"; import type { AddService, + AddServices, InjectableClass, InjectableFunction, ServicesFromTokenizedParams, @@ -78,6 +79,28 @@ type PartialContainerFactories = { * ``` */ export class PartialContainer { + /** + * Creates a new PartialContainer from a plain object containing service definitions. + * Each property of the object is registered as a value service with no dependencies. + * + * @example + * ```ts + * const partial = PartialContainer.fromObject({ apiUrl: "https://api.example.com", timeout: 5000 }); + * const container = Container.provides(partial); + * console.log(container.get('apiUrl')); // "https://api.example.com" + * ``` + * + * @param services A plain object where each property maps to a service value. + * @returns A new PartialContainer populated with the provided services and no dependencies. + */ + static fromObject(services: Services): PartialContainer { + let container: PartialContainer = new PartialContainer({}); + for (const [token, value] of entries(services)) { + container = container.providesValue(token, value); + } + return container as PartialContainer; + } + constructor(private readonly injectables: Injectables) {} /** @@ -144,6 +167,31 @@ export class PartialContainer { > : never; + /** + * Merges services from another PartialContainer into this one. + * Dependencies from both containers are combined, with any dependencies satisfied by + * the other container's services removed. + * + * @param container The PartialContainer whose services will be merged. + */ + provides( + container: PartialContainer + ): PartialContainer< + AddServices, + ExcludeKey, keyof Services | keyof AdditionalServices> + >; + + /** + * Merges services from a Container into this PartialContainer. + * Since Container services are fully resolved, they add no new dependencies + * and may satisfy existing ones. + * + * @param container The Container whose services will be merged. + */ + provides( + container: Container + ): PartialContainer, ExcludeKey>; + provides(first: any, second?: any, third?: any): PartialContainer { // Two-arg form: provides(token, factory) if (typeof second === "function") { @@ -154,7 +202,23 @@ export class PartialContainer { const fn = Injectable(first, second, third); return new PartialContainer({ ...this.injectables, [first]: fn } as any); } - // Original single-arg form + // provides(PartialContainer) + if (first instanceof PartialContainer) { + return new PartialContainer({ ...this.injectables, ...first.injectables } as any); + } + // provides(Container) — duck-type via 'factories' property + if (first && "factories" in first) { + const containerInjectables: any = {}; + for (const key of Object.keys(first.factories)) { + const factory = first.factories[key]; + const fn: any = () => factory(); + fn.token = key; + fn.dependencies = []; + containerInjectables[key] = fn; + } + return new PartialContainer({ ...this.injectables, ...containerInjectables } as any); + } + // Original single-arg form: provides(InjectableFunction) return new PartialContainer({ ...this.injectables, [first.token]: first } as any); } diff --git a/src/__tests__/PartialContainer.spec.ts b/src/__tests__/PartialContainer.spec.ts index e637c25..4ca1ecc 100644 --- a/src/__tests__/PartialContainer.spec.ts +++ b/src/__tests__/PartialContainer.spec.ts @@ -95,6 +95,79 @@ describe("PartialContainer", () => { }); }); + describe("fromObject", () => { + test("creates a PartialContainer from a plain object", () => { + const partial = PartialContainer.fromObject({ foo: 1, bar: "baz" }); + const combined = Container.provides(partial); + expect(combined.get("foo")).toBe(1); + expect(combined.get("bar")).toBe("baz"); + }); + }); + + describe("when providing another PartialContainer", () => { + test("merges services from both PartialContainers", () => { + const partial1 = new PartialContainer({}).provides("A", () => "a"); + const partial2 = new PartialContainer({}).provides("B", () => "b"); + const merged = partial1.provides(partial2); + const combined = Container.provides(merged); + expect(combined.get("A")).toBe("a"); + expect(combined.get("B")).toBe("b"); + }); + + test("services from the provided PartialContainer take precedence", () => { + const partial1 = new PartialContainer({}).provides("A", () => "old"); + const partial2 = new PartialContainer({}).provides("A", () => "new"); + const merged = partial1.provides(partial2); + expect(Container.provides(merged).get("A")).toBe("new"); + }); + + test("combines dependencies, excluding services provided by either partial", () => { + const partial1 = new PartialContainer({}).provides("A", ["X"] as const, (x: string) => x); + const partial2 = new PartialContainer({}).provides("X", () => "x-value"); + // partial1 needs X, partial2 provides X — merged should have no unresolved deps + const merged = partial1.provides(partial2); + const combined = Container.provides(merged); + expect(combined.get("A")).toBe("x-value"); + }); + + test("tracks unresolved dependencies from both partials", () => { + const partial1 = new PartialContainer({}).provides("A", ["X"] as const, (x: string) => x); + const partial2 = new PartialContainer({}).provides("B", ["Y"] as const, (y: number) => y); + const merged = partial1.provides(partial2); + // @ts-expect-error both X and Y are unresolved + Container.provides(merged); + // Providing both deps should work + Container.providesValue("X", "x").providesValue("Y", 1).provides(merged); + }); + }); + + describe("when providing a Container", () => { + test("merges Container services into the PartialContainer", () => { + const partial = new PartialContainer({}).provides("A", () => "a"); + const existingContainer = Container.providesValue("B", "b"); + const merged = partial.provides(existingContainer); + const combined = Container.provides(merged); + expect(combined.get("A")).toBe("a"); + expect(combined.get("B")).toBe("b"); + }); + + test("Container services satisfy PartialContainer dependencies", () => { + const partial = new PartialContainer({}).provides("A", ["B"] as const, (b: string) => `got ${b}`); + const existingContainer = Container.providesValue("B", "b-value"); + const merged = partial.provides(existingContainer); + // B is now provided by the merged container, so no external deps needed + const combined = Container.provides(merged); + expect(combined.get("A")).toBe("got b-value"); + }); + + test("Container services take precedence over existing PartialContainer services", () => { + const partial = new PartialContainer({}).provides("A", () => "old"); + const existingContainer = Container.providesValue("A", "new"); + const merged = partial.provides(existingContainer); + expect(Container.provides(merged).get("A")).toBe("new"); + }); + }); + describe("when providing a Service using inline token and factory", () => { test("provides a zero-dep service via provides(token, factory)", () => { const partial = new PartialContainer({}).provides("TestService", () => "testService"); From 91ed8acea9b0a1db4fc313ce0d14779ea90c1c18 Mon Sep 17 00:00:00 2001 From: Konstantin Burov Date: Thu, 5 Mar 2026 19:56:14 +1100 Subject: [PATCH 4/9] Update docs to prefer inline provides() over Injectable() Lead README and TSDoc examples with the inline provides('token', factory) and provides('token', deps, factory) forms. Reposition Injectable() as a lower-level primitive for reusable factory objects and run(). Add examples to all new PartialContainer overloads (fromObject, provides(Partial), provides(Container), inline factory forms). Co-Authored-By: Claude Opus 4.6 --- README.md | 20 ++++++++----- src/Container.ts | 66 ++++++++++------------------------------- src/Injectable.ts | 52 ++++++++++++++++++-------------- src/PartialContainer.ts | 55 ++++++++++++++++++++++++++-------- 4 files changed, 100 insertions(+), 93 deletions(-) diff --git a/README.md b/README.md index 99fd7b7..23b999b 100644 --- a/README.md +++ b/README.md @@ -58,19 +58,23 @@ const db = container.get("Database"); db.save("user1"); // Log: Saving record: user1 ``` -#### Custom Factory Functions +#### Inline Factory Functions -When a service needs custom instantiation logic — such as transformation, conditional setup, or isn't a simple class — use `Injectable()` with `.provides()`: +When a service needs custom creation logic, pass a factory function directly to `provides`: ```ts -import { Container, Injectable } from "@snap/ts-inject"; +import { Container } from "@snap/ts-inject"; + +// Zero-dependency lazy factory +const container = Container.provides("Logger", () => new Logger()); -const container = Container.providesValue("apiUrl", "https://api.example.com").provides( - Injectable("httpClient", ["apiUrl"], (url: string) => createHttpClient(url)) -); +// Factory with dependencies — tokens are resolved from the container +const appContainer = container + .providesValue("apiUrl", "https://api.example.com") + .provides("httpClient", ["apiUrl"] as const, (url: string) => createHttpClient(url)); ``` -`providesValue` and `providesClass` cover the vast majority of use cases. Reach for `Injectable()` only when you need a factory function with custom logic. +For most services, prefer `providesValue` (eager values), `providesClass` (classes with `static dependencies`), or the inline `provides` form above. The `Injectable()` helper is only needed when you need a reusable factory object — for example, to pass to `run()` for eager initialization. #### Composable Containers @@ -110,7 +114,7 @@ container.get("plugins"); // [AuthPlugin, LoggingPlugin, { name: "inline", ... } - **Service**: Any value or instance provided by the Container. - **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**: The lower-level primitive used by `Injectable()` to create factory functions with explicit dependency lists. Use this when `providesValue`/`providesClass` don't fit your needs. +- **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()`. ### API Reference diff --git a/src/Container.ts b/src/Container.ts index 62c51cf..3e5dd48 100644 --- a/src/Container.ts +++ b/src/Container.ts @@ -75,16 +75,10 @@ export class Container { static provides(container: PartialContainer | Container): Container; /** - * Creates a new [Container] by providing a Service that has no dependencies. + * Creates a new [Container] by providing a Service that has no dependencies, + * using a pre-built {@link InjectableFunction}. * - * @example - * ```ts - * // Register a single service using an InjectableFunction - * const container = Container.provides(Injectable('Logger', () => new Logger())); - * ``` - * - * **Tip:** For services without dependencies, prefer - * {@link Container.providesValue | providesValue} or {@link Container.providesClass | providesClass}. + * **Tip:** Prefer the inline form `Container.provides('token', () => value)` instead. */ static provides( fn: InjectableFunction<{}, [], Token, Service> @@ -127,8 +121,8 @@ export class Container { * const logger = new Logger(); * const container = Container.providesValue('Logger', logger); * - * // This is effectively a shortcut for the following, where an Injectable is explicitly created - * const container2 = Container.provides(Injectable('Logger', () => logger); + * // This is effectively a shortcut for + * const container2 = Container.provides('Logger', () => logger); * ``` * * @param token A unique Token identifying the service within the container. This token is used to retrieve the value. @@ -284,8 +278,8 @@ export class Container { * ```ts * // Create initializers for caching and reporting setup that depend on a request service * const initializers = new PartialContainer({}) - * .provides(Injectable("initCache", ["request"], (request: Request) => fetchAndPopulateCache(request))) - * .provides(Injectable("setupReporter", ["request"], (request: Request) => setupReporter(request))); + * .provides("initCache", ["request"] as const, (request: Request) => fetchAndPopulateCache(request)) + * .provides("setupReporter", ["request"] as const, (request: Request) => setupReporter(request)); * * // Setup the main container with a request service and run the initializers * const container = Container @@ -390,20 +384,13 @@ export class Container { ): Container>; /** - * Registers a new service in this Container using an `InjectableFunction`. This function defines how the service - * is created, including its dependencies and the token under which it will be registered. When called, this method - * adds the service to the container, ready to be retrieved via its token. - * - * The `InjectableFunction` must specify: - * - A unique `Token` identifying the service. - * - A list of `Tokens` representing the dependencies needed to create the service. + * Registers a new service in this Container using a pre-built `InjectableFunction`. * - * This method ensures type safety by verifying that all required dependencies are available in the container - * and match the expected types. If a dependency is missing or a type mismatch occurs, a compiler error is raised, - * preventing runtime errors and ensuring reliable service creation. + * **Tip:** Prefer the inline forms `provides('token', () => value)` or + * `provides('token', ['dep'] as const, (dep) => value)` instead. + * Use this overload when you have a reusable `InjectableFunction` object. * - * @param fn The `InjectableFunction` that constructs the service. It should take required dependencies as arguments - * and return the newly created service. + * @param fn The `InjectableFunction` that constructs the service. * @returns A new `Container` instance containing the added service, allowing chaining of multiple `provides` calls. */ provides[], Service>( @@ -555,23 +542,6 @@ export class Container { ConcatInjectable(token, () => this.providesClass(token, cls).get(token)) ) as Container; - /** - * Appends a new service instance to an existing array within the container using an `InjectableFunction`. - * - * @example - * ```ts - * // Assume there's a container with an array ready to hold service instances - * const container = Container.fromObject({ services: [] as Service[] }); - * // Append a new Service instance to the 'services' array using a factory function - * const newContainer = container.append(Injectable('services', () => new Service())); - * // Retrieve the services array to see the added Service instance - * console.log(newContainer.get('services').length); // prints 1; - * ``` - * - * @param fn - An injectable function that returns the Service. - * @returns The updated Container, now including the new service instance appended to the array - * specified by the token. - */ /** * Appends a new service instance to an existing array within the container using a zero-argument factory function. * @@ -618,18 +588,12 @@ export class Container { ): Container; /** - * Appends a new service instance to an existing array within the container using an `InjectableFunction`. + * Appends a new service instance to an existing array using a pre-built `InjectableFunction`. * - * @example - * ```ts - * const container = Container.fromObject({ services: [] as Service[] }); - * const newContainer = container.append(Injectable('services', () => new Service())); - * console.log(newContainer.get('services').length); // prints 1; - * ``` + * **Tip:** Prefer `append('token', () => value)` or `append('token', ['dep'] as const, fn)` instead. * * @param fn - An injectable function that returns the Service. - * @returns The updated Container, now including the new service instance appended to the array - * specified by the token. + * @returns The updated Container with the new service instance appended. */ append< Token extends keyof Services, diff --git a/src/Injectable.ts b/src/Injectable.ts index bce1c8b..5d2bfbd 100644 --- a/src/Injectable.ts +++ b/src/Injectable.ts @@ -1,18 +1,20 @@ import type { InjectableClass, InjectableFunction, ServicesFromTokenizedParams, TokenType } from "./types"; /** - * Creates an Injectable factory function designed for services without dependencies. - * This is useful for simple services or values that don't depend on other parts of the system. + * Creates a reusable Injectable factory function designed for services without dependencies. * - * **Tip:** For simple cases, prefer {@link Container.providesValue | Container.providesValue()} - * or {@link Container.providesClass | Container.providesClass()} instead. Use `Injectable()` when - * you need custom factory logic. + * **Note:** In most cases, prefer using `provides('token', () => value)` directly on + * Container or PartialContainer instead. `Injectable()` is primarily needed when you want + * a reusable factory object — for example, to pass to {@link Container.run | Container.run()}. * * @example * ```ts - * const container = Container.provides(Injectable("MyService", () => new MyService())); + * // Prefer the inline form: + * const container = Container.provides("MyService", () => new MyService()); * - * const myService = container.get("MyService"); + * // Use Injectable() when you need a reusable factory: + * const myServiceFactory = Injectable("MyService", () => new MyService()); + * container.run(myServiceFactory); // eager initialization * ``` * * @param token A unique Token identifying the Service within the container. This token @@ -35,20 +37,21 @@ export function Injectable( * **Important:** This function requires **TypeScript 5 or later** due to the use of `const` type parameters. * Users on TypeScript 4 and earlier must use {@link InjectableCompat} instead. * + * **Note:** In most cases, prefer the inline form on Container or PartialContainer: + * `provides('token', ['dep'] as const, (dep) => value)`. + * * @example * ```ts - * const dependencyB = 'DependencyB'; + * // Prefer the inline form: * const container = Container * .providesValue("DependencyA", new A()) * .providesValue("DependencyB", new B()) - * .provides(Injectable( - * "MyService", - * ["DependencyA", dependencyB] as const, // "as const" can be omitted in TypeScript 5 and later - * (a: A, b: B) => new MyService(a, b), - * ) - * ) + * .provides("MyService", ["DependencyA", "DependencyB"] as const, (a: A, b: B) => new MyService(a, b)); * - * const myService = container.get("MyService"); + * // Use Injectable() when you need a reusable factory object: + * const myServiceFactory = Injectable( + * "MyService", ["DependencyA", "DependencyB"] as const, (a: A, b: B) => new MyService(a, b) + * ); * ``` * * @param token A unique Token identifying the Service within the container. @@ -179,18 +182,23 @@ export function ClassInjectable( * to an existing array of Services of the same type. Useful for dynamically expanding * service collections without altering original service tokens or factories. * + * **Note:** Prefer using `container.append('token', () => value)` or + * `container.appendValue('token', value)` instead. + * * @example * ```ts + * // Prefer the inline form: * const container = Container - * .providesValue("values", [1]) // Initially provide an array with one value - * .provides(ConcatInjectable("values", () => 2)); // Append another value to the array + * .providesValue("values", [1]) + * .append("values", () => 2); * - * const result = container.get("values"); // Results in [1, 2] - * ``` + * // ConcatInjectable is the lower-level primitive: + * const container2 = Container + * .providesValue("values", [1]) + * .provides(ConcatInjectable("values", () => 2)); * - * In this context, `ConcatInjectable("values", () => 2)` acts as a simplified form of - * `Injectable("values", ["values"], (values: number[]) => [...values, 2])`, - * directly appending a new value to the "values" service array without the need for explicit array manipulation. + * // Both result in container.get("values") === [1, 2] + * ``` * * @param token Token identifying an existing Service array to which the new Service will be appended. * @param fn A no-argument function that returns the service to be appended. diff --git a/src/PartialContainer.ts b/src/PartialContainer.ts index 685e934..b3ff18d 100644 --- a/src/PartialContainer.ts +++ b/src/PartialContainer.ts @@ -65,17 +65,19 @@ type PartialContainerFactories = { * * Here's an example of PartialContainer usage: * ```ts - * // We can register Foo, even though the PartialContainer doesn't fulfill the Bar dependency. + * // Register services with unresolved dependencies * const partialContainer = new PartialContainer({}) * .providesClass('Foo', Foo) // Foo declares static dependencies = ['Bar'] as const + * .provides('Baz', ['Bar'] as const, (bar: Bar) => new Baz(bar)) * * // Provide the missing dependency via a Container - * const combinedContainer = Container + * const container = Container * .providesValue('Bar', new Bar()) * .provides(partialContainer) * - * // We can resolve Foo, because the combined container includes Bar, so all of Foo's dependencies are now met. - * const foo = combinedContainer.get('Foo') + * // All dependencies are now met + * const foo = container.get('Foo') + * const baz = container.get('Baz') * ``` */ export class PartialContainer { @@ -104,16 +106,12 @@ export class PartialContainer { constructor(private readonly injectables: Injectables) {} /** - * Create a new PartialContainer which provides a Service created by the given InjectableFunction. + * Create a new PartialContainer which provides a Service created by a pre-built InjectableFunction. * - * The InjectableFunction contains metadata specifying the Token by which the created Service will be known, as well - * as an ordered list of Tokens to be resolved and provided to the InjectableFunction as arguments. + * **Tip:** Prefer the inline forms `provides('token', () => value)` or + * `provides('token', ['dep'] as const, (dep) => value)` instead. * - * The dependencies are allowed to be missing from the PartialContainer, but these dependencies are maintained as a - * parameter of the returned PartialContainer. This allows `[Container.provides]` to type check the dependencies and - * ensure they can be provided by the Container. - * - * @param fn A InjectableFunction, taking dependencies as arguments, which returns the Service. + * @param fn An InjectableFunction, taking dependencies as arguments, which returns the Service. */ provides< AdditionalDependencies extends readonly any[], @@ -136,6 +134,12 @@ export class PartialContainer { /** * Create a new PartialContainer which provides a Service created by a zero-argument factory function. * + * @example + * ```ts + * const partial = new PartialContainer({}).provides('Logger', () => new Logger()); + * const container = Container.provides(partial); + * ``` + * * @param token A unique Token identifying the service. * @param fn A zero-argument factory function that creates the service. */ @@ -149,6 +153,15 @@ export class PartialContainer { * Dependencies that are not already provided by this PartialContainer will be tracked and must be * fulfilled by the Container this PartialContainer is eventually provided to. * + * @example + * ```ts + * const partial = new PartialContainer({}) + * .provides('ApiClient', ['config'] as const, (config: Config) => new ApiClient(config)); + * + * // 'config' must be provided by the Container + * const container = Container.providesValue('config', myConfig).provides(partial); + * ``` + * * @param token A unique Token identifying the service. * @param dependencies A readonly array of tokens for the factory's dependencies. * @param fn A factory function whose parameters match the dependencies. @@ -172,6 +185,16 @@ export class PartialContainer { * Dependencies from both containers are combined, with any dependencies satisfied by * the other container's services removed. * + * @example + * ```ts + * const authModule = new PartialContainer({}) + * .providesClass('AuthService', AuthService); + * + * const apiModule = new PartialContainer({}) + * .providesClass('ApiClient', ApiClient) + * .provides(authModule); + * ``` + * * @param container The PartialContainer whose services will be merged. */ provides( @@ -186,6 +209,14 @@ export class PartialContainer { * Since Container services are fully resolved, they add no new dependencies * and may satisfy existing ones. * + * @example + * ```ts + * const configContainer = Container.fromObject({ apiUrl: '...', timeout: 5000 }); + * const partial = new PartialContainer({}) + * .providesClass('ApiClient', ApiClient) + * .provides(configContainer); // satisfies ApiClient's config dependencies + * ``` + * * @param container The Container whose services will be merged. */ provides( From c2752e4f79207794f090020d9f882c9f961d3dc6 Mon Sep 17 00:00:00 2001 From: Konstantin Burov Date: Sun, 8 Mar 2026 14:09:16 +1100 Subject: [PATCH 5/9] Address PR #18 review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace duck-typing ("factories" in first) with instanceof Container - Convert arrow function properties to methods for consistency - Reduce `any` usage: type impl signature, extract addInjectable helper - Remove conditional `never` return type — error now only targets factory - Replace `void[]` with `ParamCountMismatch` sentinel for readable errors - Add regression tests and error message assertions Co-Authored-By: Claude Opus 4.6 --- src/Container.ts | 26 +++++++---- src/Injectable.ts | 17 +++---- src/PartialContainer.ts | 64 +++++++++++++++----------- src/__tests__/Container.spec.ts | 11 +++++ src/__tests__/Injectable.spec.ts | 15 +++++- src/__tests__/PartialContainer.spec.ts | 13 ++++++ 6 files changed, 98 insertions(+), 48 deletions(-) diff --git a/src/Container.ts b/src/Container.ts index 3e5dd48..1da608f 100644 --- a/src/Container.ts +++ b/src/Container.ts @@ -473,11 +473,12 @@ export class Container { * specifying these dependencies. * @returns A new Container instance containing the newly created service, allowing for method chaining. */ - providesClass = []>( + providesClass[]>( token: Token, cls: InjectableClass - ): Container> => - this.providesService(ClassInjectable(token, cls)) as Container>; + ): Container> { + return this.providesService(ClassInjectable(token, cls)) as Container>; + } /** * Registers a static value as a service in the container. This method is ideal for services that do not @@ -489,10 +490,12 @@ export class Container { * @returns A new Container instance that includes the provided service, allowing for chaining additional * `provides` calls. */ - providesValue = ( + providesValue( token: Token, value: Service - ): Container> => this.providesService(Injectable(token, [], () => value)); + ): Container> { + return this.providesService(Injectable(token, [], () => value)); + } /** * Appends a value to the array associated with a specified token in the current Container, then returns @@ -510,10 +513,12 @@ export class Container { * @param value - A value to append to the array. * @returns The updated Container with the appended value in the specified array. */ - appendValue = >( + appendValue>( token: Token, value: Service - ): Container => this.providesService(ConcatInjectable(token, () => value)) as Container; + ): Container { + return this.providesService(ConcatInjectable(token, () => value)) as Container; + } /** * Appends an injectable class factory to the array associated with a specified token in the current Container, @@ -530,17 +535,18 @@ export class Container { * @param cls - A class with a constructor that takes dependencies as arguments, which returns the Service. * @returns The updated Container with the new service instance appended to the specified array. */ - appendClass = < + appendClass< Token extends keyof Services, Tokens extends readonly ValidTokens[], Service extends ArrayElement, >( token: Token, cls: InjectableClass - ): Container => - this.providesService( + ): Container { + return this.providesService( ConcatInjectable(token, () => this.providesClass(token, cls).get(token)) ) as Container; + } /** * Appends a new service instance to an existing array within the container using a zero-argument factory function. diff --git a/src/Injectable.ts b/src/Injectable.ts index 5d2bfbd..4bfeb2c 100644 --- a/src/Injectable.ts +++ b/src/Injectable.ts @@ -1,5 +1,9 @@ import type { InjectableClass, InjectableFunction, ServicesFromTokenizedParams, TokenType } from "./types"; +/** Sentinel type used to produce readable compiler errors when factory param count doesn't match deps. */ +export type ParamCountMismatch = + "Error: factory parameter count must match dependency count" & { readonly __brand: unique symbol }; + /** * Creates a reusable Injectable factory function designed for services without dependencies. * @@ -69,12 +73,9 @@ export function Injectable< token: Token, dependencies: Tokens, // The function arity (number of arguments) must match the number of dependencies specified – if they don't, we'll - // force a compiler error by saying the arguments should be `void[]`. We'll also throw at runtime, so the return - // type will be `never`. - fn: (...args: Tokens["length"] extends Params["length"] ? Params : void[]) => Service -): Tokens["length"] extends Params["length"] - ? InjectableFunction, Tokens, Token, Service> - : never; + // force a compiler error via the ParamCountMismatch sentinel type. We'll also throw at runtime. + fn: (...args: Tokens["length"] extends Params["length"] ? Params : ParamCountMismatch[]) => Service +): InjectableFunction, Tokens, Token, Service>; export function Injectable( token: TokenType, @@ -121,7 +122,7 @@ export function InjectableCompat< >( token: Token, dependencies: Tokens, - fn: (...args: Tokens["length"] extends Params["length"] ? Params : void[]) => Service + fn: (...args: Tokens["length"] extends Params["length"] ? Params : ParamCountMismatch[]) => Service ): ReturnType { return Injectable(token, dependencies, fn); } @@ -236,7 +237,7 @@ export function ConcatInjectable< >( token: Token, dependencies: Tokens, - fn: (...args: Tokens["length"] extends Params["length"] ? Params : void[]) => Service + fn: (...args: Tokens["length"] extends Params["length"] ? Params : ParamCountMismatch[]) => Service ): InjectableFunction, Tokens, Token, Service[]>; export function ConcatInjectable( diff --git a/src/PartialContainer.ts b/src/PartialContainer.ts index b3ff18d..361ae3e 100644 --- a/src/PartialContainer.ts +++ b/src/PartialContainer.ts @@ -1,7 +1,7 @@ import { entries } from "./entries"; import type { Memoized } from "./memoize"; import { memoize } from "./memoize"; -import type { Container } from "./Container"; +import { Container } from "./Container"; import type { AddService, AddServices, @@ -11,7 +11,7 @@ import type { TokenType, ValidTokens, } from "./types"; -import type { ConstructorReturnType } from "./Injectable"; +import type { ConstructorReturnType, ParamCountMismatch } from "./Injectable"; import { ClassInjectable, Injectable } from "./Injectable"; // Using a conditional type forces TS language services to evaluate the type -- so when showing e.g. type hints, we @@ -169,16 +169,14 @@ export class PartialContainer { provides( token: Token, dependencies: Tokens, - fn: (...args: Tokens["length"] extends Params["length"] ? Params : void[]) => Service - ): Tokens["length"] extends Params["length"] - ? PartialContainer< - AddService, - ExcludeKey< - AddDependencies, ServicesFromTokenizedParams>, - keyof Services - > - > - : never; + fn: (...args: Tokens["length"] extends Params["length"] ? Params : ParamCountMismatch[]) => Service + ): PartialContainer< + AddService, + ExcludeKey< + AddDependencies, ServicesFromTokenizedParams>, + keyof Services + > + >; /** * Merges services from another PartialContainer into this one. @@ -223,34 +221,41 @@ export class PartialContainer { container: Container ): PartialContainer, ExcludeKey>; - provides(first: any, second?: any, third?: any): PartialContainer { + provides( + first: PartialInjectableFunction | PartialContainer | Container | TokenType, + second?: (() => any) | readonly TokenType[], + third?: (...args: any[]) => any + ): PartialContainer { // Two-arg form: provides(token, factory) if (typeof second === "function") { - return new PartialContainer({ ...this.injectables, [first]: Injectable(first, second) } as any); + return this.addInjectable(first as TokenType, Injectable(first as TokenType, second)); } // Three-arg form: provides(token, dependencies, factory) if (Array.isArray(second) && typeof third === "function") { - const fn = Injectable(first, second, third); - return new PartialContainer({ ...this.injectables, [first]: fn } as any); + return this.addInjectable(first as TokenType, Injectable(first as TokenType, second, third)); } // provides(PartialContainer) if (first instanceof PartialContainer) { return new PartialContainer({ ...this.injectables, ...first.injectables } as any); } - // provides(Container) — duck-type via 'factories' property - if (first && "factories" in first) { - const containerInjectables: any = {}; + // provides(Container) + if (first instanceof Container) { + const containerInjectables: Record> = {}; for (const key of Object.keys(first.factories)) { const factory = first.factories[key]; - const fn: any = () => factory(); - fn.token = key; - fn.dependencies = []; - containerInjectables[key] = fn; + containerInjectables[key] = Injectable(key, () => factory()); } return new PartialContainer({ ...this.injectables, ...containerInjectables } as any); } // Original single-arg form: provides(InjectableFunction) - return new PartialContainer({ ...this.injectables, [first.token]: first } as any); + return this.addInjectable((first as any).token, first as any); + } + + private addInjectable( + token: TokenType, + fn: InjectableFunction + ): PartialContainer { + return new PartialContainer({ ...this.injectables, [token]: fn } as any); } /** @@ -266,8 +271,9 @@ export class PartialContainer { * @param token the Token by which the value will be known. * @param value the value to be provided. */ - providesValue = (token: Token, value: Service) => - this.provides(Injectable(token, [], () => value)); + providesValue(token: Token, value: Service) { + return this.provides(Injectable(token, [], () => value)); + } /** * Create a new PartialContainer which provides the given class as a Service, all of the class's dependencies will be @@ -288,7 +294,7 @@ export class PartialContainer { * @param token the Token by which the class will be known. * @param cls the class to be provided, must match the InjectableClass type. */ - providesClass = < + providesClass< Class extends InjectableClass, AdditionalDependencies extends ConstructorParameters, Tokens extends Class["dependencies"], @@ -297,7 +303,9 @@ export class PartialContainer { >( token: Token, cls: Class - ) => this.provides(ClassInjectable(token, cls)); + ) { + return this.provides(ClassInjectable(token, cls)); + } /** * In order to create a [Container], the InjectableFunctions maintained by the PartialContainer must be memoized diff --git a/src/__tests__/Container.spec.ts b/src/__tests__/Container.spec.ts index 973bd48..f3d4aed 100644 --- a/src/__tests__/Container.spec.ts +++ b/src/__tests__/Container.spec.ts @@ -377,6 +377,17 @@ describe("Container", () => { }); }); + test("type error targets factory when arity doesn't match deps", () => { + expect(() => + Container.providesValue("bar", "hello").provides( + "Foo", + ["bar"] as const, + // @ts-expect-error factory has 2 params but only 1 dependency + (bar: string, extra: number) => bar + ) + ).toThrowError(TypeError); + }); + describe("when retrieving a Service", () => { test("an Error is thrown if the Container does not contain the Service", () => { // We have to force an error here – without the `as any`, this fails to compile (which typically protects diff --git a/src/__tests__/Injectable.spec.ts b/src/__tests__/Injectable.spec.ts index 65febad..85ecc96 100644 --- a/src/__tests__/Injectable.spec.ts +++ b/src/__tests__/Injectable.spec.ts @@ -3,13 +3,24 @@ import { Injectable } from "../Injectable"; describe("Injectable", () => { describe("when given invalid arguments", () => { test("a TypeError is thrown", () => { - expect(() => Injectable("TestService", [] as any)).toThrowError(TypeError); + expect(() => Injectable("TestService", [] as any)).toThrowError( + /Received invalid arguments/ + ); }); }); + test("type error targets factory when arity doesn't match deps", () => { + expect(() => + // @ts-expect-error factory has 2 params but only 1 dependency + Injectable("Foo", ["bar"] as const, (bar: string, extra: number) => bar) + ).toThrowError(/Function arity does not match/); + }); + describe("when given a function with arity unequal to the number of dependencies", () => { test("a TypeError is thrown", () => { - expect(() => Injectable("TestService", [] as const, (_: any) => {})).toThrowError(TypeError); + expect(() => Injectable("TestService", [] as const, (_: any) => {})).toThrowError( + /Function arity does not match/ + ); }); }); }); diff --git a/src/__tests__/PartialContainer.spec.ts b/src/__tests__/PartialContainer.spec.ts index 4ca1ecc..838c12d 100644 --- a/src/__tests__/PartialContainer.spec.ts +++ b/src/__tests__/PartialContainer.spec.ts @@ -195,6 +195,19 @@ describe("PartialContainer", () => { }); }); + test("type error targets factory when arity doesn't match deps", () => { + expect(() => + new PartialContainer({}) + .provides( + "Foo", + ["bar"] as const, + // @ts-expect-error factory has 2 params but only 1 dependency + (bar: string, extra: number) => bar + ) + .provides("Baz", () => "baz") // should compile — no `never` propagation + ).toThrowError(TypeError); + }); + describe("when providing a Service using the same Token as an existing Service", () => { describe("provided by the PartialContainer", () => { describe("and the new Service does not depend on the old Service", () => { From d4c9448b07d7b5553272ad1f2022ca76bb40e9ae Mon Sep 17 00:00:00 2001 From: Konstantin Burov Date: Sun, 8 Mar 2026 14:17:19 +1100 Subject: [PATCH 6/9] Fix prettier formatting Co-Authored-By: Claude Opus 4.6 --- src/Container.ts | 5 +---- src/Injectable.ts | 5 +++-- src/PartialContainer.ts | 5 +---- src/__tests__/Injectable.spec.ts | 4 +--- src/__tests__/PartialContainer.spec.ts | 19 ++++++++++--------- 5 files changed, 16 insertions(+), 22 deletions(-) diff --git a/src/Container.ts b/src/Container.ts index 1da608f..2e78b03 100644 --- a/src/Container.ts +++ b/src/Container.ts @@ -539,10 +539,7 @@ export class Container { Token extends keyof Services, Tokens extends readonly ValidTokens[], Service extends ArrayElement, - >( - token: Token, - cls: InjectableClass - ): Container { + >(token: Token, cls: InjectableClass): Container { return this.providesService( ConcatInjectable(token, () => this.providesClass(token, cls).get(token)) ) as Container; diff --git a/src/Injectable.ts b/src/Injectable.ts index 4bfeb2c..95c0b5a 100644 --- a/src/Injectable.ts +++ b/src/Injectable.ts @@ -1,8 +1,9 @@ import type { InjectableClass, InjectableFunction, ServicesFromTokenizedParams, TokenType } from "./types"; /** Sentinel type used to produce readable compiler errors when factory param count doesn't match deps. */ -export type ParamCountMismatch = - "Error: factory parameter count must match dependency count" & { readonly __brand: unique symbol }; +export type ParamCountMismatch = "Error: factory parameter count must match dependency count" & { + readonly __brand: unique symbol; +}; /** * Creates a reusable Injectable factory function designed for services without dependencies. diff --git a/src/PartialContainer.ts b/src/PartialContainer.ts index 361ae3e..5f101ba 100644 --- a/src/PartialContainer.ts +++ b/src/PartialContainer.ts @@ -300,10 +300,7 @@ export class PartialContainer { Tokens extends Class["dependencies"], Service extends ConstructorReturnType, Token extends TokenType, - >( - token: Token, - cls: Class - ) { + >(token: Token, cls: Class) { return this.provides(ClassInjectable(token, cls)); } diff --git a/src/__tests__/Injectable.spec.ts b/src/__tests__/Injectable.spec.ts index 85ecc96..a19d384 100644 --- a/src/__tests__/Injectable.spec.ts +++ b/src/__tests__/Injectable.spec.ts @@ -3,9 +3,7 @@ import { Injectable } from "../Injectable"; describe("Injectable", () => { describe("when given invalid arguments", () => { test("a TypeError is thrown", () => { - expect(() => Injectable("TestService", [] as any)).toThrowError( - /Received invalid arguments/ - ); + expect(() => Injectable("TestService", [] as any)).toThrowError(/Received invalid arguments/); }); }); diff --git a/src/__tests__/PartialContainer.spec.ts b/src/__tests__/PartialContainer.spec.ts index 838c12d..cf20994 100644 --- a/src/__tests__/PartialContainer.spec.ts +++ b/src/__tests__/PartialContainer.spec.ts @@ -196,15 +196,16 @@ describe("PartialContainer", () => { }); test("type error targets factory when arity doesn't match deps", () => { - expect(() => - new PartialContainer({}) - .provides( - "Foo", - ["bar"] as const, - // @ts-expect-error factory has 2 params but only 1 dependency - (bar: string, extra: number) => bar - ) - .provides("Baz", () => "baz") // should compile — no `never` propagation + expect( + () => + new PartialContainer({}) + .provides( + "Foo", + ["bar"] as const, + // @ts-expect-error factory has 2 params but only 1 dependency + (bar: string, extra: number) => bar + ) + .provides("Baz", () => "baz") // should compile — no `never` propagation ).toThrowError(TypeError); }); From 3cf005fffdb136f2d1d2ca2262f0b8c1f5afa742 Mon Sep 17 00:00:00 2001 From: Konstantin Burov Date: Tue, 10 Mar 2026 11:36:29 +1100 Subject: [PATCH 7/9] Improve ParamCountMismatch type to avoid property expansion in errors The branded string intersection (`string & { __brand: unique symbol }`) caused TS to expand structural comparisons against complex types like Date, producing verbose "missing properties" errors. Using an interface with a descriptive `never` property keeps the type name visible in errors without structural expansion. Co-Authored-By: Claude Opus 4.6 --- src/Injectable.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Injectable.ts b/src/Injectable.ts index 95c0b5a..cedbf18 100644 --- a/src/Injectable.ts +++ b/src/Injectable.ts @@ -1,9 +1,10 @@ import type { InjectableClass, InjectableFunction, ServicesFromTokenizedParams, TokenType } from "./types"; /** Sentinel type used to produce readable compiler errors when factory param count doesn't match deps. */ -export type ParamCountMismatch = "Error: factory parameter count must match dependency count" & { - readonly __brand: unique symbol; -}; +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface ParamCountMismatch { + readonly "Error: factory parameter count must match dependency count": never; +} /** * Creates a reusable Injectable factory function designed for services without dependencies. From 67676744b3873e25fca1344fb3bd93c93e2b97c1 Mon Sep 17 00:00:00 2001 From: Konstantin Burov Date: Wed, 11 Mar 2026 10:18:37 +1100 Subject: [PATCH 8/9] Bump version to 0.4.0 Breaking: arrow function properties converted to methods (providesClass, providesValue, appendValue, appendClass). Co-Authored-By: Claude Opus 4.6 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 969e9f8..0c95e3e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@snap/ts-inject", - "version": "0.3.2", + "version": "0.4.0", "description": "100% typesafe dependency injection framework for TypeScript projects", "license": "MIT", "author": "Snap Inc.", From 2dbffcbbc18872528b3ae9b324cb720c841f3977 Mon Sep 17 00:00:00 2001 From: Mikalai Silivonik Date: Tue, 10 Mar 2026 20:50:01 -0400 Subject: [PATCH 9/9] also updated lock file --- package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 984f050..b62decd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@snap/ts-inject", - "version": "0.3.1", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@snap/ts-inject", - "version": "0.3.1", + "version": "0.4.0", "license": "MIT", "devDependencies": { "@types/jest": "^29.5.12",