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
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

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.3.2",
"version": "0.4.0",
"description": "100% typesafe dependency injection framework for TypeScript projects",
"license": "MIT",
"author": "Snap Inc.",
Expand Down
63 changes: 38 additions & 25 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,11 +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>[],
Params extends MapTokensToTypes<Services, Tokens>,
Service,
>(
token: Token,
cls: InjectableClass<Services, Service, Tokens>
cls: {
readonly dependencies: Tokens;
new (...args: Params): Service;
}
): Container<AddService<Services, Token, Service>> =>
this.providesService(ClassInjectable(token, cls)) as Container<AddService<Services, Token, Service>>;
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 Down Expand Up @@ -455,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
): Container<Services> => 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 @@ -478,14 +486,16 @@ export class Container<Services = {}> {
appendClass = <
Token extends keyof Services,
Tokens extends readonly ValidTokens<Services>[],
Service extends ArrayElement<Services[Token]>,
Params extends MapTokensToTypes<Services, Tokens>,
Service,
>(
token: Token,
cls: InjectableClass<Services, Service, Tokens>
): Container<Services> =>
this.providesService(
ConcatInjectable(token, () => this.providesClass(token, cls).get(token))
) as Container<Services>;
cls: {
readonly dependencies: Tokens;
new (...args: Params): Service;
}
): Container<AddService<Services, Token, Service[]>> =>
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 @@ -507,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>
): Container<Services> =>
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
81 changes: 13 additions & 68 deletions src/Injectable.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -56,18 +56,15 @@ export function Injectable<Token extends TokenType, Service>(
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<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 +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.` +
Expand Down Expand Up @@ -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<any, any, any>,
Dependencies extends ConstructorParameters<Class>,
Token extends TokenType,
Tokens extends Class["dependencies"],
>(
token: Token,
cls: Class
): InjectableFunction<ServicesFromTokenizedParams<Tokens, Dependencies>, Tokens, Token, ConstructorReturnType<Class>>;

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 Expand Up @@ -219,12 +165,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 +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)}`
);
Expand All @@ -257,5 +204,3 @@ export function ConcatInjectable(
factory.dependencies = [token, ...dependencies];
return factory;
}

export type ConstructorReturnType<T> = T extends new (...args: any) => infer C ? C : any;
Loading
Loading