Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Partial container API improvements #6

Merged
merged 7 commits into from
Sep 19, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);
}

/**
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate comment

* 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>> {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was almost complete duplicate of providesService method, which we can now reuse.

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) =>
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

re-using providesService

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>;
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

re-using providesService


/**
* 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>>;
}
}
23 changes: 22 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,27 @@ export function Injectable(
return factory;
}

/**
* Creates an Injectable factory function for an InjectableClass.
*
* @param token Token identifying the Service.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's provide an example how this can be used similarly how we do that for other exported stuff here

* @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
37 changes: 37 additions & 0 deletions src/__tests__/PartialContainer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,43 @@ describe("PartialContainer", () => {
});
});

describe("provide service using provideValue", () => {
const dependenciesContainer = Container.provides(Injectable("TestService", () => "old service"));

describe("and the new Service does not override", () => {
const partialContainer = new PartialContainer({}).providesValue("NewTestService", "new service");
expect(dependenciesContainer.provides(partialContainer).get("NewTestService")).toEqual("new service");
});

describe("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", () => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please replace describe with test here and in other case. the expectation should be inside test block.

const partialContainer = new PartialContainer({}).providesClass("NewTestService", NewTestService);
expect(dependenciesContainer.provides(partialContainer).get("NewTestService")).toBeInstanceOf(NewTestService);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we need an expectation that a dependency is missing, something line:

const partialContainer = new PartialContainer({}).providesClass("NewTestService", NewTestService);
// @ts-expect-error
expect(() => Container.provides(partialContainer).get("NewTestService")).toThrow(
  '[Container::get] Could not find Service for Token "TestService". ' +
    "This should've caused a compile-time error. If the Token is 'undefined', " +
    "check all your calls to the Injectable function. Make sure you define dependencies " +
    "using string literals or string constants that are definitely initialized before the call to Injectable."
);

Would be nice to have that for other provide* methods on partial container.

});

describe("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 }>;
Expand Down