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

Refactored some types to avoid "as" casting #15

Open
wants to merge 15 commits into
base: main
Choose a base branch
from
70 changes: 43 additions & 27 deletions src/Container.ts
Original file line number Diff line number Diff line change
@@ -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<Services> = {
[K in keyof Services]: (() => Services[K]) | Memoized<() => Services[K]>;
Expand Down Expand Up @@ -142,8 +141,8 @@ export class Container<Services = {}> {
* 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 extends { [s: string]: any }>(services: Services): Container<Services> {
return entries(services).reduce(
static fromObject<Services extends { [s: TokenType]: any }>(services: Services): Container<Services> {
return Object.entries(services).reduce(
(container, [token, value]) => container.providesValue(token, value),
new Container({})
) as Container<Services>;
Expand Down Expand Up @@ -277,7 +276,7 @@ export class Container<Services = {}> {
* in the provided {@link PartialContainer} initialized as needed.
*/
run<AdditionalServices, Dependencies, FulfilledDependencies extends Dependencies>(
// FullfilledDependencies is assignable to Dependencies -- by specifying Container<FulfilledDependencies> as the
// FulfilledDependencies is assignable to Dependencies -- by specifying Container<FulfilledDependencies> as the
// `this` type, we ensure this Container can provide all the Dependencies required by the PartialContainer.
this: Container<FulfilledDependencies>,
container: PartialContainer<AdditionalServices, Dependencies>
Expand Down Expand Up @@ -340,7 +339,7 @@ export class Container<Services = {}> {
* `PartialContainer`, with services from the `PartialContainer` taking precedence in case of conflicts.
*/
provides<AdditionalServices, Dependencies, FulfilledDependencies extends Dependencies>(
// FullfilledDependencies is assignable to Dependencies -- by specifying Container<FulfilledDependencies> as the
// FulfilledDependencies is assignable to Dependencies -- by specifying Container<FulfilledDependencies> as the
// `this` type, we ensure this Container can provide all the Dependencies required by the PartialContainer.
this: Container<FulfilledDependencies>,
container: PartialContainer<AdditionalServices, Dependencies>
Expand Down Expand Up @@ -418,10 +417,19 @@ 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,
Tokens extends readonly ValidTokens<Services>[],
Class extends {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Nice, does it mean we can remove InjectableClass and ClassInjectable now?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

yes, removed that.

readonly dependencies: Tokens;
new (...args: Params): InstanceType<Class>;
},
Params extends MapTokensToTypes<Services, Class["dependencies"]>,
>(
token: Token,
cls: InjectableClass<Services, Service, Tokens>
) => this.providesService(ClassInjectable(token, cls));
cls: Class
): Container<AddService<Services, Token, InstanceType<Class>>> =>
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
Expand All @@ -433,8 +441,10 @@ 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) =>
this.providesService(Injectable(token, [], () => value));
providesValue = <Token extends TokenType, Service>(
token: Token,
value: Service
): Container<AddService<Services, Token, 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 @@ -452,10 +462,11 @@ 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>(
token: Token,
value: Service
) => this.providesService(ConcatInjectable(token, () => value)) as Container<Services>;
value: ArrayElement<Services[Token]>
): Container<AddService<Services, Token, ArrayElement<Services[Token]>[]>> =>
this.providesService(ConcatInjectable(token, () => value));

/**
* Appends an injectable class factory to the array associated with a specified token in the current Container,
Expand All @@ -475,14 +486,16 @@ export class Container<Services = {}> {
appendClass = <
Token extends keyof Services,
Tokens extends readonly ValidTokens<Services>[],
Service extends ArrayElement<Services[Token]>,
Class extends {
readonly dependencies: Tokens;
new (...args: Params): InstanceType<Class>;
},
Params extends MapTokensToTypes<Services, Class["dependencies"]>,
>(
token: Token,
cls: InjectableClass<Services, Service, Tokens>
) =>
this.providesService(
ConcatInjectable(token, () => this.providesClass(token, cls).get(token))
) as Container<Services>;
cls: Class
): Container<AddService<Services, Token, InstanceType<Class>[]>> =>
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`.
Expand All @@ -504,13 +517,16 @@ export class Container<Services = {}> {
append = <
Token extends keyof Services,
Tokens extends readonly ValidTokens<Services>[],
Service extends ArrayElement<Services[Token]>,
Fn extends {
(...args: Params): ArrayElement<Services[Token]>;
token: Token;
dependencies: Tokens;
},
Params extends MapTokensToTypes<Services, Fn["dependencies"]>,
>(
fn: InjectableFunction<Services, Tokens, Token, Service>
) =>
this.providesService(
ConcatInjectable(fn.token, () => this.providesService(fn).get(fn.token))
) as Container<Services>;
fn: Tokens extends readonly TokenType[] ? Fn : never
): Container<AddService<Services, Token, ArrayElement<Services[Token]>[]>> =>
this.providesService(ConcatInjectable(fn.token, fn.dependencies, (...args: Params) => fn(...args)));

private providesService<
Token extends TokenType,
Expand Down
23 changes: 12 additions & 11 deletions src/Injectable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,18 +56,17 @@ export function Injectable<Token extends TokenType, Service>(
export function Injectable<
Token extends TokenType,
const Tokens extends readonly TokenType[],
Params extends readonly any[],
Params extends readonly any[] & { length: Tokens["length"] },
Service,
Deps extends ServicesFromTokenizedParams<Tokens, Params>,
>(
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<ServicesFromTokenizedParams<Tokens, Params>, Tokens, Token, Service>
: never;
fn: (...args: Params) => Service
): InjectableFunction<Deps, Tokens, Token, Service>;

export function Injectable(
token: TokenType,
Expand All @@ -79,11 +78,12 @@ 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) {
// const length = actualLength in fn ? fn[actualLength] as number : fn.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.` +
Expand Down Expand Up @@ -219,12 +219,13 @@ export function ConcatInjectable<Token extends TokenType, Service>(
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<Tokens, Params>,
>(
token: Token,
dependencies: Tokens,
fn: (...args: Tokens["length"] extends Params["length"] ? Params : void[]) => Service
fn: (...args: Params) => Service
): InjectableFunction<ServicesFromTokenizedParams<Tokens, Params>, Tokens, Token, Service[]>;

export function ConcatInjectable(
Expand All @@ -242,9 +243,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)}`
);
Expand Down
19 changes: 18 additions & 1 deletion src/__tests__/Injectable.spec.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,32 @@
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);
});
});

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);
});
});
});
6 changes: 5 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,16 @@ export type InjectableFunction<
Service,
> = Tokens extends readonly ValidTokens<Services>[]
? {
(...args: AsTuple<CorrespondingServices<Services, Tokens>>): Service;
(...args: MapTokensToTypes<Services, Tokens>): Service;
token: Token;
dependencies: Tokens;
}
: never;

export type MapTokensToTypes<Services, Tokens extends readonly ValidTokens<Services>[]> = AsTuple<
CorrespondingServices<Services, Tokens>
>;

/**
* 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
Expand Down