diff --git a/package.json b/package.json index 6a70cdf..72711ed 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@snap/ts-inject", - "version": "0.1.2", + "version": "0.2.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 a75811b..ba10659 100644 --- a/src/Container.ts +++ b/src/Container.ts @@ -1,8 +1,8 @@ -import { isMemoized, memoize } from "./memoize"; 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 { ConcatInjectable } from "./Injectable"; +import { ClassInjectable, ConcatInjectable, Injectable } from "./Injectable"; import { entries } from "./entries"; type MaybeMemoizedFactories = { @@ -407,13 +407,6 @@ export class Container { return this.providesService(fnOrContainer); } - /** - * Create a new Container which provides a Service created by the given [InjectableClass]. - * - * @param token - A unique Token which will correspond to the created Service. - * @param cls - A class with a constructor that takes dependencies as arguments, which returns the Service. - */ - /** * Registers a service in the container using a class constructor, simplifying the service creation process. * @@ -425,22 +418,10 @@ 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> { - const dependencies: readonly any[] = cls.dependencies; - // If the service depends on itself, e.g. in the multi-binding case, where we call append multiple times with - // the same token, we always must resolve the dependency using the parent container to avoid infinite loop. - const getFromParent = dependencies.indexOf(token) !== -1 ? () => this.get(token as any) : undefined; - const factory = memoize(this, function (this: Container) { - // Safety: getFromParent is defined if the token is in the dependencies list, so it is safe to call it. - return new cls(...(dependencies.map((t) => (t === token ? getFromParent!() : this.get(t))) as any)); - }); - - const factories = { ...this.factories, [token]: factory }; - return new Container(factories as unknown as MaybeMemoizedFactories>); - } + ) => this.providesService(ClassInjectable(token, cls)); /** * Registers a static value as a service in the container. This method is ideal for services that do not @@ -452,14 +433,8 @@ export class Container { * @returns A new Container instance that includes the provided service, allowing for chaining additional * `provides` calls. */ - providesValue( - token: Token, - value: Service - ): Container> { - const factory = memoize(this, () => value); - const factories = { ...this.factories, [token]: factory }; - return new Container(factories as unknown as MaybeMemoizedFactories>); - } + providesValue = (token: Token, value: Service) => + this.providesService(Injectable(token, [], () => value)); /** * Appends a value to the array associated with a specified token in the current Container, then returns @@ -477,14 +452,10 @@ 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 - ): Service extends any ? Container : never; - - appendValue(token: Token, value: Service): Container { - return this.providesService(ConcatInjectable(token, () => value)); - } + ) => this.providesService(ConcatInjectable(token, () => value)) as Container; /** * Appends an injectable class factory to the array associated with a specified token in the current Container, @@ -501,18 +472,17 @@ 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): Service extends any ? Container : never; - - appendClass[], Service>( + >( token: Token, cls: InjectableClass - ): Container { - return this.providesService(ConcatInjectable(token, () => this.providesClass(token, cls).get(token))); - } + ) => + 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`. @@ -531,17 +501,16 @@ export class Container { * @returns The updated Container, now including the new service instance appended to the array * specified by the token. */ - append< + append = < Token extends keyof Services, Tokens extends readonly ValidTokens[], Service extends ArrayElement, - >(fn: InjectableFunction): Service extends any ? Container : never; - - append[], Service>( + >( fn: InjectableFunction - ): Container { - return this.providesService(ConcatInjectable(fn.token, () => this.providesService(fn).get(fn.token))); - } + ) => + this.providesService( + ConcatInjectable(fn.token, () => this.providesService(fn).get(fn.token)) + ) as Container; private providesService[], Service>( fn: InjectableFunction @@ -559,6 +528,6 @@ export class Container { // MaybeMemoizedFactories object with the expected set of services – but when using the spread operation to // merge two objects, the compiler widens the Token type to string. So we must re-narrow via casting. const factories = { ...this.factories, [token]: factory }; - return new Container(factories as unknown as MaybeMemoizedFactories>); + return new Container(factories) as Container>; } } diff --git a/src/Injectable.ts b/src/Injectable.ts index e74ca5c..01e5311 100644 --- a/src/Injectable.ts +++ b/src/Injectable.ts @@ -1,4 +1,4 @@ -import type { InjectableFunction, ServicesFromTokenizedParams, TokenType } from "./types"; +import type { InjectableClass, InjectableFunction, ServicesFromTokenizedParams, TokenType } from "./types"; /** * Creates an Injectable factory function designed for services without dependencies. @@ -100,6 +100,49 @@ export function Injectable( return factory; } +/** + * Creates an Injectable factory function for an InjectableClass. + * + * @example + * ```ts + * class InjectableClassService { + * static dependencies = ["service"] as const; + * constructor(public service: string) {} + * public print(): string { + * console.log(this.service); + * } + * } + * + * let container = Container.provides("service", "service value") + * .provides(ClassInjectable("classService", InjectableClassService)); + * + * container.get("classService").print(); // prints "service value" + * + * // prefer using Container's provideClass method. Above is the equivalent of: + * container = Container.provides("service", "service value") + * .providesClass("classService", InjectableClassService); + * + * container.get("classService").print(); // prints "service value" + * ``` + * + * @param token Token identifying the Service. + * @param cls InjectableClass to instantiate. + */ +export function ClassInjectable( + token: Token, + cls: InjectableClass +): InjectableFunction; + +export function ClassInjectable( + token: TokenType, + cls: InjectableClass +): InjectableFunction { + const factory = (...args: any[]) => new cls(...args); + factory.token = token; + factory.dependencies = cls.dependencies; + return factory; +} + /** * Creates an Injectable factory function without dependencies that appends a Service * to an existing array of Services of the same type. Useful for dynamically expanding diff --git a/src/PartialContainer.ts b/src/PartialContainer.ts index a49236e..8799eda 100644 --- a/src/PartialContainer.ts +++ b/src/PartialContainer.ts @@ -2,7 +2,17 @@ import { entries } from "./entries"; import { memoize } from "./memoize"; import type { Memoized } from "./memoize"; import type { Container } from "./Container"; -import type { AddService, InjectableFunction, ServicesFromTokenizedParams, TokenType, ValidTokens } from "./types"; +import type { + AddService, + InjectableClass, + InjectableFunction, + ServicesFromTokenizedParams, + TokenType, + ValidTokens, +} from "./types"; +import { ClassInjectable, Injectable } from "./Injectable"; + +type ConstructorReturnType = T extends new (...args: any) => infer C ? C : any; // Using a conditional type forces TS language services to evaluate the type -- so when showing e.g. type hints, we // will see the mapped type instead of the AddDependencies type alias. This produces better hints. @@ -77,7 +87,7 @@ export class PartialContainer { * 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. * - * This dependencies are allowed to be missing from the PartialContainer, but these dependencies are maintained as a + * 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. * @@ -103,6 +113,52 @@ export class PartialContainer { return new PartialContainer({ ...this.injectables, [fn.token]: fn } as any); } + /** + * Create a new PartialContainer which provides the given value as a Service. + * + * Example: + * ```ts + * const partial = new PartialContainer({}).providesValue("value", 42); + * const value = Container.provides(partial).get("value"); + * console.log(value); // 42 + * ``` + * + * @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)); + + /** + * Create a new PartialContainer which provides the given class as a Service, all of the class's dependencies will be + * resolved by the parent Container. + * + * Example: + * ```ts + * class Foo { + * static dependencies = ['bar'] as const; + * constructor(public bar: string) {} + * } + * + * const partial = new PartialContainer({}).providesClass("foo", Foo); + * const foo = Container.providesValue("bar", "bar value").provides(partial).get("foo"); + * console.log(foo.bar); // "bar value" + * ``` + * + * @param token the Token by which the class will be known. + * @param cls the class to be provided, must match the InjectableClass type. + */ + 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)); + /** * In order to create a [Container], the InjectableFunctions maintained by the PartialContainer must be memoized * into Factories that can resolve their dependencies and return the correct Service. diff --git a/src/__tests__/Container.spec.ts b/src/__tests__/Container.spec.ts index 10523f1..0a67495 100644 --- a/src/__tests__/Container.spec.ts +++ b/src/__tests__/Container.spec.ts @@ -1,3 +1,4 @@ +// eslint-disable-next-line max-classes-per-file import { Container } from "../Container"; import { Injectable } from "../Injectable"; import { PartialContainer } from "../PartialContainer"; @@ -141,6 +142,53 @@ describe("Container", () => { }); }); + describe("when providing a Service using providesClass", () => { + const container = Container.providesValue("value", 1); + + test("test simple case", () => { + class Item { + static dependencies = ["value"] as const; + constructor(public value: number) {} + } + const containerWithService = container.providesClass("service", Item); + expect(containerWithService.get("service")).toEqual(new Item(1)); + }); + + test("error if class constructor arity doesn't match dependencies", () => { + class Item { + static dependencies = ["value", "value2"] as const; + constructor(public value: number) {} + } + // @ts-expect-error should be failing to compile as the constructor doesn't match dependencies + expect(() => container.providesClass("service", Item).get("service")).toThrow(); + // should not fail now as we provide the missing dependency + container.providesValue("value2", 2).providesClass("service", Item).get("service"); + }); + + test("error if class constructor argument type doesn't match provided by container", () => { + class Item { + static dependencies = ["value"] as const; + constructor(public value: string) {} + } + // @ts-expect-error must fail to compile as the constructor argument type doesn't match dependencies + container.providesClass("service", Item).get("service"); + // should not fail now as we provide the correct type + container.providesValue("value", "1").providesClass("service", Item).get("service"); + }); + + test("error if class constructor argument type doesn't match provided by container", () => { + class Item { + static dependencies = ["value"] as const; + constructor( + public value: number, + public value2: string + ) {} + } + // @ts-expect-error must fail to compile as the constructor arity type doesn't match dependencies array length + container.providesValue("value2", "2").providesClass("service", Item).get("service"); + }); + }); + describe("when providing a PartialContainer", () => { let service1: InjectableFunction; let service2: InjectableFunction; diff --git a/src/__tests__/PartialContainer.spec.ts b/src/__tests__/PartialContainer.spec.ts index b71bbc4..9780d12 100644 --- a/src/__tests__/PartialContainer.spec.ts +++ b/src/__tests__/PartialContainer.spec.ts @@ -132,7 +132,7 @@ describe("PartialContainer", () => { () => { expect(() => { new Container({}).provides(containerWithService).get("TestService"); - }).toThrow(); + }).toThrow(/Could not find Service for Token "TestService"/); new Container({}) .provides(Injectable("TestService", () => "old service from container")) @@ -152,6 +152,51 @@ describe("PartialContainer", () => { }); }); + describe("provide service using provideValue", () => { + const dependenciesContainer = Container.provides(Injectable("TestService", () => "old service")); + + test("and the new Service does not override", () => { + const partialContainer = new PartialContainer({}).providesValue("NewTestService", "new service"); + expect(dependenciesContainer.provides(partialContainer).get("NewTestService")).toEqual("new service"); + }); + + test("and the new Service does override", () => { + const partialContainer = new PartialContainer({}).providesValue("TestService", "new service"); + expect(dependenciesContainer.provides(partialContainer).get("TestService")).toEqual("new service"); + }); + }); + + describe("provide service using provideClass", () => { + const dependenciesContainer = Container.provides(Injectable("TestService", () => "old service")); + + class NewTestService { + static dependencies = ["TestService"] as const; + constructor(public testService: string) {} + } + + describe("and the new Service does not override", () => { + const partialContainer = new PartialContainer({}).providesClass("NewTestService", NewTestService); + test("fails if parent missing dependency", () => { + // @ts-expect-error should be a compile error because nothing provides "TestService" + expect(() => Container.provides(partialContainer).get("NewTestService")).toThrow( + /Could not find Service for Token "TestService"/ + ); + }); + test("succeeds if parent has dependency", () => { + expect(dependenciesContainer.provides(partialContainer).get("NewTestService")).toBeInstanceOf(NewTestService); + }); + }); + + test("and the new Service does override", () => { + const partialContainer = new PartialContainer({}) + .providesValue("TestService", "old service") + .providesClass("TestService", NewTestService); + let testService = dependenciesContainer.provides(partialContainer).get("TestService"); + expect(testService).toBeInstanceOf(NewTestService); + expect(testService.testService).toEqual("old service"); + }); + }); + describe("provided by an existing Container", () => { const dependenciesContainer = Container.provides(Injectable("TestService", () => "old service")); let combinedContainer: Container<{ TestService: string }>; diff --git a/src/types.ts b/src/types.ts index b9dc936..a7b3288 100644 --- a/src/types.ts +++ b/src/types.ts @@ -50,14 +50,12 @@ export type InjectableFunction< : never; export type InjectableClass = Tokens extends readonly ValidTokens[] - ? ClassWithInjections + ? { + readonly dependencies: Tokens; + new (...args: AsTuple>): Service; + } : never; -export interface ClassWithInjections[]> { - readonly dependencies: Tokens; - new (...args: AsTuple>): Service; -} - export type AnyInjectable = InjectableFunction; export type ServicesFromInjectables = {