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/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", 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.", diff --git a/src/Container.ts b/src/Container.ts index 21ae70e..2e78b03 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"; @@ -67,30 +75,37 @@ 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}. + * + * **Tip:** Prefer the inline form `Container.provides('token', () => value)` instead. + */ + static provides( + fn: InjectableFunction<{}, [], Token, Service> + ): 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 - * // Register a single service using an InjectableFunction - * const container = Container.provides(Injectable('Logger', () => new Logger())); + * const container = Container.provides('Logger', () => new Logger()); * ``` - * - * **Tip:** For services without dependencies, prefer - * {@link Container.providesValue | providesValue} or {@link Container.providesClass | providesClass}. */ static provides( - fn: InjectableFunction<{}, [], Token, Service> + token: Token, + fn: () => Service ): Container>; - static provides( - fnOrContainer: InjectableFunction<{}, [], TokenType, any> | PartialContainer | Container - ): 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); } /** @@ -106,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. @@ -263,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 @@ -369,44 +384,82 @@ 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>( 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); } /** @@ -420,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 @@ -436,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 @@ -457,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, @@ -477,45 +535,95 @@ 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( + >(token: Token, cls: InjectableClass): 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 an `InjectableFunction`. + * Appends a new service instance to an existing array within the container using a zero-argument factory function. * * @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; + * 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, + const Tokens extends readonly ValidTokens[], + Service extends ArrayElement, + >( + 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 using a pre-built `InjectableFunction`. + * + * **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 = < + append< Token extends keyof Services, Tokens extends readonly ValidTokens[], Service extends ArrayElement, - >( - fn: InjectableFunction - ): Container => - this.providesService( - ConcatInjectable(fn.token, () => this.providesService(fn).get(fn.token)) + >(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/Injectable.ts b/src/Injectable.ts index bce1c8b..cedbf18 100644 --- a/src/Injectable.ts +++ b/src/Injectable.ts @@ -1,18 +1,26 @@ import type { InjectableClass, InjectableFunction, ServicesFromTokenizedParams, TokenType } from "./types"; +/** Sentinel type used to produce readable compiler errors when factory param count doesn't match deps. */ +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface ParamCountMismatch { + readonly "Error: factory parameter count must match dependency count": never; +} + /** - * 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 +43,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. @@ -66,12 +75,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, @@ -118,7 +124,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); } @@ -179,18 +185,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. @@ -228,7 +239,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 4b85aa3..5f101ba 100644 --- a/src/PartialContainer.ts +++ b/src/PartialContainer.ts @@ -1,16 +1,17 @@ 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, InjectableClass, InjectableFunction, ServicesFromTokenizedParams, 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 @@ -64,33 +65,53 @@ 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 { + /** + * 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) {} /** - * Create a new PartialContainer which provides a Service created by the given 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. + * Create a new PartialContainer which provides a Service created by a pre-built InjectableFunction. * - * 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. + * **Tip:** Prefer the inline forms `provides('token', () => value)` or + * `provides('token', ['dep'] as const, (dep) => value)` instead. * - * @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[], @@ -108,8 +129,133 @@ 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. + * + * @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. + */ + 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. + * + * @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. + */ + provides( + token: Token, + dependencies: Tokens, + 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. + * 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( + 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. + * + * @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( + container: Container + ): PartialContainer, ExcludeKey>; + + 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 this.addInjectable(first as TokenType, Injectable(first as TokenType, second)); + } + // Three-arg form: provides(token, dependencies, factory) + if (Array.isArray(second) && typeof third === "function") { + 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) + if (first instanceof Container) { + const containerInjectables: Record> = {}; + for (const key of Object.keys(first.factories)) { + const factory = first.factories[key]; + containerInjectables[key] = Injectable(key, () => factory()); + } + return new PartialContainer({ ...this.injectables, ...containerInjectables } as any); + } + // Original single-arg form: provides(InjectableFunction) + 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); } /** @@ -125,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 @@ -147,16 +294,15 @@ 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"], Service extends ConstructorReturnType, Token extends TokenType, - >( - token: Token, - cls: Class - ) => this.provides(ClassInjectable(token, cls)); + >(token: Token, cls: Class) { + 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 0a67495..f3d4aed 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>; @@ -265,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); @@ -318,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..a19d384 100644 --- a/src/__tests__/Injectable.spec.ts +++ b/src/__tests__/Injectable.spec.ts @@ -3,13 +3,22 @@ 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 9780d12..cf20994 100644 --- a/src/__tests__/PartialContainer.spec.ts +++ b/src/__tests__/PartialContainer.spec.ts @@ -95,6 +95,120 @@ 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"); + 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); + }); + }); + + 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", () => {