Skip to content

Commit

Permalink
Partial container API improvements (#6)
Browse files Browse the repository at this point in the history
- Added `providesValue` and `providesClass` to the `PartialContainer`
API
- Extracted common logic for initialising class-based injectables
  • Loading branch information
kburov-sc authored Sep 19, 2024
1 parent 3346f61 commit d716d2a
Show file tree
Hide file tree
Showing 7 changed files with 222 additions and 63 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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.",
Expand Down
73 changes: 21 additions & 52 deletions src/Container.ts
Original file line number Diff line number Diff line change
@@ -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<Services> = {
Expand Down Expand Up @@ -407,13 +407,6 @@ export class Container<Services = {}> {
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.
*
Expand All @@ -425,22 +418,10 @@ export class Container<Services = {}> {
* specifying these dependencies.
* @returns A new Container instance containing the newly created service, allowing for method chaining.
*/
providesClass<Token extends TokenType, Service, Tokens extends readonly ValidTokens<Services>[]>(
providesClass = <Token extends TokenType, Service, Tokens extends readonly ValidTokens<Services>[]>(
token: Token,
cls: InjectableClass<Services, Service, Tokens>
): Container<AddService<Services, Token, Service>> {
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<Services>) {
// 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<AddService<Services, Token, Service>>);
}
) => this.providesService(ClassInjectable(token, cls));

/**
* Registers a static value as a service in the container. This method is ideal for services that do not
Expand All @@ -452,14 +433,8 @@ export class Container<Services = {}> {
* @returns A new Container instance that includes the provided service, allowing for chaining additional
* `provides` calls.
*/
providesValue<Token extends TokenType, Service>(
token: Token,
value: Service
): Container<AddService<Services, Token, Service>> {
const factory = memoize(this, () => value);
const factories = { ...this.factories, [token]: factory };
return new Container(factories as unknown as MaybeMemoizedFactories<AddService<Services, Token, Service>>);
}
providesValue = <Token extends TokenType, Service>(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
Expand All @@ -477,14 +452,10 @@ export class Container<Services = {}> {
* @param value - A value to append to the array.
* @returns The updated Container with the appended value in the specified array.
*/
appendValue<Token extends keyof Services, Service extends ArrayElement<Services[Token]>>(
appendValue = <Token extends keyof Services, Service extends ArrayElement<Services[Token]>>(
token: Token,
value: Service
): Service extends any ? Container<Services> : never;

appendValue<Token extends TokenType, Service>(token: Token, value: Service): Container<any> {
return this.providesService(ConcatInjectable(token, () => value));
}
) => this.providesService(ConcatInjectable(token, () => value)) as Container<Services>;

/**
* Appends an injectable class factory to the array associated with a specified token in the current Container,
Expand All @@ -501,18 +472,17 @@ export class Container<Services = {}> {
* @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<Services>[],
Service extends ArrayElement<Services[Token]>,
>(token: Token, cls: InjectableClass<Services, Service, Tokens>): Service extends any ? Container<Services> : never;

appendClass<Token extends TokenType, Tokens extends readonly ValidTokens<Services>[], Service>(
>(
token: Token,
cls: InjectableClass<Services, Service, Tokens>
): Container<any> {
return this.providesService(ConcatInjectable(token, () => this.providesClass(token, cls).get(token)));
}
) =>
this.providesService(
ConcatInjectable(token, () => this.providesClass(token, cls).get(token))
) as Container<Services>;

/**
* Appends a new service instance to an existing array within the container using an `InjectableFunction`.
Expand All @@ -531,17 +501,16 @@ export class Container<Services = {}> {
* @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<Services>[],
Service extends ArrayElement<Services[Token]>,
>(fn: InjectableFunction<Services, Tokens, Token, Service>): Service extends any ? Container<Services> : never;

append<Token extends TokenType, Tokens extends readonly ValidTokens<Services>[], Service>(
>(
fn: InjectableFunction<Services, Tokens, Token, Service>
): Container<any> {
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<Services>;

private providesService<Token extends TokenType, Tokens extends readonly ValidTokens<Services>[], Service>(
fn: InjectableFunction<Services, Tokens, Token, Service>
Expand All @@ -559,6 +528,6 @@ export class Container<Services = {}> {
// 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<AddService<Services, Token, Service>>);
return new Container(factories) as Container<AddService<Services, Token, Service>>;
}
}
45 changes: 44 additions & 1 deletion src/Injectable.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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<Services, Token extends TokenType, const Tokens extends readonly TokenType[], Service>(
token: Token,
cls: InjectableClass<Services, Service, Tokens>
): InjectableFunction<Services, Tokens, Token, Service>;

export function ClassInjectable(
token: TokenType,
cls: InjectableClass<any, any, readonly TokenType[]>
): InjectableFunction<any, readonly TokenType[], TokenType, any> {
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
Expand Down
60 changes: 58 additions & 2 deletions src/PartialContainer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> = 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.
Expand Down Expand Up @@ -77,7 +87,7 @@ export class PartialContainer<Services = {}, Dependencies = {}> {
* 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.
*
Expand All @@ -103,6 +113,52 @@ export class PartialContainer<Services = {}, Dependencies = {}> {
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 extends TokenType, Service>(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<any, any, any>,
AdditionalDependencies extends ConstructorParameters<Class>,
Tokens extends Class["dependencies"],
Service extends ConstructorReturnType<Class>,
Token extends TokenType,
>(
token: Token,
cls: Class
) => this.provides<AdditionalDependencies, Tokens, Token, Service>(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.
Expand Down
48 changes: 48 additions & 0 deletions src/__tests__/Container.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// eslint-disable-next-line max-classes-per-file
import { Container } from "../Container";
import { Injectable } from "../Injectable";
import { PartialContainer } from "../PartialContainer";
Expand Down Expand Up @@ -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<any, [], "Service1", string>;
let service2: InjectableFunction<any, [], "Service2", number>;
Expand Down
Loading

0 comments on commit d716d2a

Please sign in to comment.