diff --git a/README.md b/README.md index c860256..42bea42 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,6 @@ db.save("user2"); // Log: Saving record: user2 - **Service**: Any value or instance provided by the Container. - **Token**: A unique identifier for each service, used for registration and retrieval within the Container. - **InjectableFunction**: Functions that return service instances. They can include dependencies which are injected when the service is requested. -- **InjectableClass**: Classes that can be instantiated by the Container. Dependencies should be specified in a static "dependencies" field to enable proper injection. ### 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 87ad6cb..aeebbaa 100644 --- a/src/Container.ts +++ b/src/Container.ts @@ -1,9 +1,8 @@ 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 { ClassInjectable, ConcatInjectable, Injectable } from "./Injectable"; -import { entries } from "./entries"; +import type { AddService, AddServices, MapTokensToTypes, InjectableFunction, TokenType, ValidTokens } from "./types"; +import { ConcatInjectable, Injectable } from "./Injectable"; type MaybeMemoizedFactories = { [K in keyof Services]: (() => Services[K]) | Memoized<() => Services[K]>; @@ -142,8 +141,8 @@ export class Container { * defines the initial set of services to be contained within the new Container instance. * @returns A new Container instance populated with the provided services. */ - static fromObject(services: Services): Container { - return entries(services).reduce( + static fromObject(services: Services): Container { + return Object.entries(services).reduce( (container, [token, value]) => container.providesValue(token, value), new Container({}) ) as Container; @@ -277,7 +276,7 @@ export class Container { * in the provided {@link PartialContainer} initialized as needed. */ run( - // FullfilledDependencies is assignable to Dependencies -- by specifying Container as the + // FulfilledDependencies is assignable to Dependencies -- by specifying Container as the // `this` type, we ensure this Container can provide all the Dependencies required by the PartialContainer. this: Container, container: PartialContainer @@ -340,7 +339,7 @@ export class Container { * `PartialContainer`, with services from the `PartialContainer` taking precedence in case of conflicts. */ provides( - // FullfilledDependencies is assignable to Dependencies -- by specifying Container as the + // FulfilledDependencies is assignable to Dependencies -- by specifying Container as the // `this` type, we ensure this Container can provide all the Dependencies required by the PartialContainer. this: Container, container: PartialContainer @@ -418,11 +417,19 @@ export class Container { * specifying these dependencies. * @returns A new Container instance containing the newly created service, allowing for method chaining. */ - providesClass = []>( + providesClass = < + Token extends TokenType, + Tokens extends readonly ValidTokens[], + Params extends MapTokensToTypes, + Service, + >( token: Token, - cls: InjectableClass + cls: { + readonly dependencies: Tokens; + new (...args: Params): Service; + } ): Container> => - this.providesService(ClassInjectable(token, cls)) as Container>; + this.providesService(Injectable(token, cls.dependencies, (...args: Params) => new cls(...args))); /** * Registers a static value as a service in the container. This method is ideal for services that do not @@ -455,10 +462,11 @@ 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; + value: ArrayElement + ): Container[]>> => + this.providesService(ConcatInjectable(token, () => value)); /** * Appends an injectable class factory to the array associated with a specified token in the current Container, @@ -478,14 +486,16 @@ export class Container { appendClass = < Token extends keyof Services, Tokens extends readonly ValidTokens[], - Service extends ArrayElement, + Params extends MapTokensToTypes, + Service, >( token: Token, - cls: InjectableClass - ): Container => - this.providesService( - ConcatInjectable(token, () => this.providesClass(token, cls).get(token)) - ) as Container; + cls: { + readonly dependencies: Tokens; + new (...args: Params): Service; + } + ): Container> => + this.providesService(ConcatInjectable(token, cls.dependencies, (...args: Params) => new cls(...args))); /** * Appends a new service instance to an existing array within the container using an `InjectableFunction`. @@ -507,13 +517,16 @@ export class Container { append = < Token extends keyof Services, Tokens extends readonly ValidTokens[], - Service extends ArrayElement, + Fn extends { + (...args: Params): ArrayElement; + token: Token; + dependencies: Tokens; + }, + Params extends MapTokensToTypes, >( - fn: InjectableFunction - ): Container => - this.providesService( - ConcatInjectable(fn.token, () => this.providesService(fn).get(fn.token)) - ) as Container; + fn: Tokens extends readonly TokenType[] ? Fn : never + ): Container[]>> => + this.providesService(ConcatInjectable(fn.token, fn.dependencies, (...args: Params) => fn(...args))); private providesService< Token extends TokenType, diff --git a/src/Injectable.ts b/src/Injectable.ts index 2fecce9..1bc4a36 100644 --- a/src/Injectable.ts +++ b/src/Injectable.ts @@ -1,4 +1,4 @@ -import type { InjectableClass, InjectableFunction, ServicesFromTokenizedParams, TokenType } from "./types"; +import type { InjectableFunction, ServicesFromTokenizedParams, TokenType } from "./types"; /** * Creates an Injectable factory function designed for services without dependencies. @@ -56,18 +56,15 @@ export function Injectable( export function Injectable< Token extends TokenType, const Tokens extends readonly TokenType[], - Params extends readonly any[], + // The function arity (number of arguments) must match the number of dependencies specified + Params extends readonly any[] & { length: Tokens["length"] }, Service, + Deps extends ServicesFromTokenizedParams, >( 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; + fn: (...args: Params) => Service +): InjectableFunction; export function Injectable( token: TokenType, @@ -79,11 +76,11 @@ export function Injectable( if (!fn) { throw new TypeError( - "[Injectable] Received invalid arguments. The factory function must be either the second " + "or third argument." + "[Injectable] Received invalid arguments. The factory function must be either the second or third argument." ); } - if (fn.length !== dependencies.length) { + if (fn.length !== 0 && fn.length !== dependencies.length) { throw new TypeError( "[Injectable] Function arity does not match the number of dependencies. Function has arity " + `${fn.length}, but ${dependencies.length} dependencies were specified.` + @@ -119,57 +116,6 @@ export function InjectableCompat< return Injectable(token, dependencies, fn); } -/** - * Creates an Injectable factory function for an InjectableClass. - * - * @example - * ```ts - * class Logger { - * static dependencies = ["config"] as const; - * constructor(private config: string) {} - * public print() { - * console.log(this.config); - * } - * } - * - * const container = Container - * .providesValue("config", "value") - * .provides(ClassInjectable("logger", Logger)); - * - * container.get("logger").print(); // prints "value" - * ``` - * - * It is recommended to use the `Container.provideClass()` method. The example above is equivalent to: - * ```ts - * const container = Container - * .providesValue("config", "value") - * .providesClass("logger", Logger); - * container.get("logger").print(); // prints "value" - * ``` - * - * @param token Token identifying the Service. - * @param cls InjectableClass to instantiate. - */ -export function ClassInjectable< - Class extends InjectableClass, - Dependencies extends ConstructorParameters, - Token extends TokenType, - Tokens extends Class["dependencies"], ->( - token: Token, - cls: Class -): InjectableFunction, Tokens, Token, ConstructorReturnType>; - -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 @@ -219,12 +165,13 @@ export function ConcatInjectable( export function ConcatInjectable< Token extends TokenType, const Tokens extends readonly TokenType[], - Params extends readonly any[], + Params extends readonly any[] & { length: Tokens["length"] }, Service, + Deps extends ServicesFromTokenizedParams, >( token: Token, dependencies: Tokens, - fn: (...args: Tokens["length"] extends Params["length"] ? Params : void[]) => Service + fn: (...args: Params) => Service ): InjectableFunction, Tokens, Token, Service[]>; export function ConcatInjectable( @@ -242,9 +189,9 @@ export function ConcatInjectable( ); } - if (fn.length !== dependencies.length) { + if (fn.length !== 0 && fn.length !== dependencies.length) { throw new TypeError( - "[Injectable] Function arity does not match the number of dependencies. Function has arity " + + "[ConcatInjectable] Function arity does not match the number of dependencies. Function has arity " + `${fn.length}, but ${dependencies.length} dependencies were specified.` + `\nDependencies: ${JSON.stringify(dependencies)}` ); @@ -257,5 +204,3 @@ export function ConcatInjectable( factory.dependencies = [token, ...dependencies]; return factory; } - -export type ConstructorReturnType = T extends new (...args: any) => infer C ? C : any; diff --git a/src/PartialContainer.ts b/src/PartialContainer.ts index ab69297..b524bb1 100644 --- a/src/PartialContainer.ts +++ b/src/PartialContainer.ts @@ -2,30 +2,75 @@ import { entries } from "./entries"; import type { Memoized } from "./memoize"; import { memoize } from "./memoize"; import type { Container } from "./Container"; -import type { - AddService, - InjectableClass, - InjectableFunction, - ServicesFromTokenizedParams, - TokenType, - ValidTokens, -} from "./types"; -import type { ConstructorReturnType } from "./Injectable"; -import { ClassInjectable, Injectable } from "./Injectable"; +import type { AddService, InjectableFunction, ServicesFromTokenizedParams, TokenType, ValidTokens } from "./types"; +import { Injectable } from "./Injectable"; -// 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. -type AddDependencies = ParentDependencies extends any +/** + * Combines two dependency maps into one, merging properties from both `ParentDependencies` and `Dependencies`. + * If a key exists in both, the value from `ParentDependencies` is used. + * + * This type is used to aggregate dependencies in a way that provides better type hints and readability in IDEs, + * because using a conditional type forces TypeScript to evaluate and display the resulting mapped type directly, + * rather than just the type alias name. + * + * @typeParam ExistingDependencies - The existing set of dependencies. + * @typeParam NewDependencies - The new dependencies to add. + * + * @remarks + * The use of a mapped type over an intersection type produces more concise and informative type hints. + * + * @example + * ```typescript + * type A = { foo: number; bar: string }; + * type B = { bar: boolean; baz: Date }; + * type Combined = AddDependencies; + * // Combined is { foo: number; bar: string; baz: Date } + * ``` + */ +type AddDependencies = ExistingDependencies extends any ? // A mapped type produces better, more concise type hints than an intersection type. { - [K in keyof ParentDependencies | keyof Dependencies]: K extends keyof ParentDependencies - ? ParentDependencies[K] - : K extends keyof Dependencies - ? Dependencies[K] + [K in keyof ExistingDependencies | keyof NewDependencies]: K extends keyof ExistingDependencies + ? ExistingDependencies[K] + : K extends keyof NewDependencies + ? NewDependencies[K] : never; } : never; +/** + * Updates the dependencies of a container by combining existing dependencies with new ones, + * while excluding any dependencies that are already satisfied by the container's existing services + * or the new service being provided. + * + * Specifically: + * - It removes the `NewToken` from `ExistingDependencies` to avoid circular dependencies. + * - It excludes keys from `NewDependencies` that are already present in `ExistingServices` + * since those dependencies are already resolved. + * - It then combines the resulting dependencies into a new dependency map. + * + * @typeParam ExistingServices - The services already provided by the container. + * @typeParam ExistingDependencies - The current dependencies of the container. + * @typeParam NewToken - The token of the new service being added to the container. + * @typeParam NewDependencies - The dependencies required by the new service. + * + * @example + * ```typescript + * type ExistingServices = { foo: number }; + * type ExistingDependencies = { bar: string; baz: boolean }; + * type NewToken = 'qux'; + * type NewDependencies = { baz: boolean; quux: Date }; + * type Updated = UpdateDependencies; + * // Updated is { bar: string; quux: Date } + * ``` + */ +type UpdateDependencies< + ExistingServices, + ExistingDependencies, + NewToken extends TokenType, + NewDependencies, +> = AddDependencies, ExcludeKey>; + type ExcludeKey = T extends any ? { [K in Exclude]: T[K] } : never; type PartialInjectableFunction< @@ -101,13 +146,7 @@ export class PartialContainer { fn: PartialInjectableFunction ): PartialContainer< AddService, - // The dependencies of the new PartialContainer are the combined dependencies of this container and the - // PartialInjectableFunction -- but we exclude any dependencies already provided by this container (i.e. this - // container's Services) as well as the new Service being provided. - ExcludeKey< - AddDependencies, ServicesFromTokenizedParams>, - keyof Services - > + UpdateDependencies> > { return new PartialContainer({ ...this.injectables, [fn.token]: fn } as any); } @@ -125,7 +164,10 @@ 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) => + providesValue = ( + token: Token, + value: Service + ): PartialContainer, UpdateDependencies> => this.provides(Injectable(token, [], () => value)); /** @@ -145,18 +187,28 @@ 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. + * @param cls the class to be provided. */ providesClass = < - Class extends InjectableClass, - AdditionalDependencies extends ConstructorParameters, - Tokens extends Class["dependencies"], - Service extends ConstructorReturnType, Token extends TokenType, + Tokens extends readonly TokenType[], + Params extends readonly any[] & { length: Tokens["length"] }, + Service, >( token: Token, - cls: Class - ) => this.provides(ClassInjectable(token, cls)); + cls: { + readonly dependencies: Tokens; + new (...args: Params): Service; + } + ): PartialContainer< + AddService, + UpdateDependencies> + > => { + const injectable = (...args: Params) => new cls(...args); + injectable.dependencies = cls.dependencies; + injectable.token = token; + return this.provides(injectable); + }; /** * 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..705d8be 100644 --- a/src/__tests__/Container.spec.ts +++ b/src/__tests__/Container.spec.ts @@ -152,6 +152,7 @@ describe("Container", () => { } const containerWithService = container.providesClass("service", Item); expect(containerWithService.get("service")).toEqual(new Item(1)); + expect(containerWithService.factories.service().value).toBe(1); }); test("error if class constructor arity doesn't match dependencies", () => { diff --git a/src/__tests__/Injectable.spec.ts b/src/__tests__/Injectable.spec.ts index 65febad..0cf4a7c 100644 --- a/src/__tests__/Injectable.spec.ts +++ b/src/__tests__/Injectable.spec.ts @@ -1,6 +1,18 @@ import { Injectable } from "../Injectable"; describe("Injectable", () => { + test("is created with not dependencies specified", () => { + expect(() => Injectable("TestService", () => {})).not.toThrow(); + }); + + test("is created with empty array specified", () => { + expect(() => Injectable("TestService", [], () => {})).not.toThrow(); + }); + + test("is created with dependency specified", () => { + expect(() => Injectable("TestService", ["a"], (_a: number) => {})).not.toThrow(); + }); + describe("when given invalid arguments", () => { test("a TypeError is thrown", () => { expect(() => Injectable("TestService", [] as any)).toThrowError(TypeError); @@ -8,8 +20,13 @@ describe("Injectable", () => { }); describe("when given a function with arity unequal to the number of dependencies", () => { - test("a TypeError is thrown", () => { + test("a compilation error is thrown", () => { + // @ts-expect-error must fail to compile as the factory function arity doesn't match dependencies array expect(() => Injectable("TestService", [] as const, (_: any) => {})).toThrowError(TypeError); }); + + test("a TypeError is thrown", () => { + expect(() => Injectable("TestService", [] as const, ((_: any) => {}) as any)).toThrowError(TypeError); + }); }); }); diff --git a/src/entries.ts b/src/entries.ts index cbc93ea..96114c1 100644 --- a/src/entries.ts +++ b/src/entries.ts @@ -1,7 +1,3 @@ // `Object.entries` does not use `keyof` types, so it loses type specificity. We'll fix this with a wrapper. export const entries = , U>(o: T): Array<[keyof T, T[keyof T]]> => Object.entries(o) as unknown as Array<[keyof T, T[keyof T]]>; - -// `Object.fromEntries` similarly does not preserve key types. -export const fromEntries = (entries: ReadonlyArray<[K, V]>): Record => - Object.fromEntries(entries) as Record; diff --git a/src/index.ts b/src/index.ts index d17449d..25a5639 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ export { CONTAINER, Container } from "./Container"; export { Injectable, InjectableCompat, ConcatInjectable } from "./Injectable"; export { PartialContainer } from "./PartialContainer"; -export { InjectableFunction, InjectableClass, ServicesFromInjectables } from "./types"; +export { InjectableFunction, ServicesFromInjectables } from "./types"; diff --git a/src/types.ts b/src/types.ts index d29a561..eaa2939 100644 --- a/src/types.ts +++ b/src/types.ts @@ -43,23 +43,26 @@ export type InjectableFunction< Service, > = Tokens extends readonly ValidTokens[] ? { - (...args: AsTuple>): Service; + (...args: MapTokensToTypes): Service; token: Token; dependencies: Tokens; } : never; /** - * Represents a class that can be used as an injectable service within a dependency injection {@link Container}. - * The `InjectableClass` type ensures that the class's dependencies and constructor signature align with - * the services available in the container, providing strong type safety. + * Maps a tuple of tokens to their corresponding service types based on the given Services type mapping. + * + * @example + * ```typescript + * type Services = { foo: string; bar: number; }; + * type Tokens = ['foo', 'bar']; + * type Result = MapTokensToTypes; + * // Result is [string, number] + * ``` */ -export type InjectableClass = Tokens extends readonly ValidTokens[] - ? { - readonly dependencies: Tokens; - new (...args: AsTuple>): Service; - } - : never; +export type MapTokensToTypes[]> = AsTuple< + CorrespondingServices +>; export type AnyInjectable = InjectableFunction;