Skip to content

Commit

Permalink
feat(providers): add support for multi-providers (#8)
Browse files Browse the repository at this point in the history
  • Loading branch information
dirkluijk authored Sep 20, 2024
1 parent 1703346 commit b563a1c
Show file tree
Hide file tree
Showing 11 changed files with 480 additions and 147 deletions.
3 changes: 3 additions & 0 deletions .prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"printWidth": 120
}
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ A light-weight TypeScript-first library for dependency injection.

* Stand-alone, no need to install other dependencies
* Intended for both JavaScript and TypeScript projects
* Supports three-shakeable injection tokens
* Inspired by [Angular](https://angular.dev/) and [InversifyJS](https://github.com/inversify/InversifyJS)
* Uses native [ECMAScript TC39 decorators](https://github.com/tc39/proposal-decorators) (currently stage 3)
* No need for `experimentalDecorators` and `emitDecoratorMetadata`
Expand Down Expand Up @@ -64,6 +65,7 @@ Check out the [advanced examples](#advanced-examples) below to learn more!
* Static values
* Dynamic factories
* Async factories
* Multi providers
* Inheritance support

## Limitations
Expand All @@ -75,7 +77,6 @@ However, if you prefer a light-weight library that works out of the box and prov

* Extend the Container API
* Scoping
* Multi-injection
* ...

Please file an issue if you like to propose new features.
Expand Down
10 changes: 2 additions & 8 deletions src/container.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
bootstrap,
bootstrapAsync,
Container,
inject,
injectAsync,
} from "./container.js";
import { afterEach, describe, expect, it, vi } from "vitest";
import { bootstrap, bootstrapAsync, Container, inject, injectAsync } from "./container.js";
import { injectable } from "./decorators.js";
import { InjectionToken } from "./tokens.js";

Expand Down
212 changes: 120 additions & 92 deletions src/container.ts
Original file line number Diff line number Diff line change
@@ -1,162 +1,194 @@
import {
type Token,
isClassToken,
toString,
isInjectionToken,
} from "./tokens.js";
import { type Token, isClassToken, toString, isInjectionToken } from "./tokens.js";
import {
isClassProvider,
isFactoryProvider,
isConstructorProvider,
isValueProvider,
type Provider,
isAsyncFactoryProvider,
isMultiProvider,
isExistingProvider,
} from "./providers.js";
import { getInjectableTarget, isInjectable } from './decorators.js';
import { getInjectableTargets, isInjectable } from "./decorators.js";

export class Container {
private providers: ProviderMap = new Map();
private singletons: SingletonMap = new Map();

bind<T>(provider: Provider<T>): this {
const token = isConstructorProvider(provider) ? provider : provider.provide;
const multi = isMultiProvider(provider);

if (isExistingProvider(provider) && provider.provide === provider.useExisting) {
throw Error(`A provider with "useExisting" cannot refer to itself`);
}

if (this.providers.has(token)) {
// todo: log warning, since we're overwriting a provider?
// alternatively, provide both .bind() and .rebind() semantics?
if (!isExistingProvider(provider) && this.singletons.has(token)) {
throw Error(
`Cannot bind a new provider for ${toString(token)}, since the existing provider was already constructed.`,
);
}

this.providers.set(token, provider);
const existingProviders = this.providers.get(token) ?? [];

if (multi && existingProviders.some((it) => !isMultiProvider(it))) {
// todo: should we be this strict, or only throw an error in mismatches upon retrieving?
throw Error(
`Cannot bind ${toString(token)} as multi-provider, since there is already a provider which is not a multi-provider.`,
);
} else if (!multi && existingProviders.some((it) => isMultiProvider(it))) {
throw Error(
`Cannot bind ${toString(token)} as provider, since there are already provider(s) that are multi-providers.`,
);
}

if (multi) {
this.providers.set(token, [...existingProviders, provider]);
} else {
if (existingProviders.length > 0) {
// todo: log warning, since we're overwriting a provider?
// alternatively, provide both .bind() and .rebind() semantics?
}

this.providers.set(token, [provider]);
}

// todo: should support eagerly resolved providers or not?

return this;
}

get<T>(token: Token<T>, options: { multi: true }): T[];
get<T>(token: Token<T>, options: { optional: true }): T | undefined;
get<T>(token: Token<T>, options?: { optional: boolean }): T;
get<T>(token: Token<T>, options?: { optional: boolean }): T | undefined {
get<T>(token: Token<T>, options: { multi: true; optional: true }): T[] | undefined;
get<T>(token: Token<T>, options?: { optional?: boolean; multi?: boolean }): T;
get<T>(token: Token<T>, options?: { optional?: boolean; multi?: boolean }): T | T[] | undefined {
this.autoBindIfNeeded(token);

if (!this.providers.has(token)) {
if (options?.optional) {
return undefined;
}
throw Error(`No provider found for ${toString(token)}`);
throw Error(`No provider(s) found for ${toString(token)}`);
}

const provider = assertNotNull(this.providers.get(token));
const providers = assertPresent(this.providers.get(token));

if (!this.singletons.has(token)) {
if (isAsyncFactoryProvider(provider)) {
if (providers.some(isAsyncFactoryProvider)) {
throw new Error(
`Provider for token ${toString(token)} is async, please use injectAsync() or container.getAsync() instead`,
`One or more providers for token ${toString(token)} are async, please use injectAsync() or container.getAsync() instead`,
);
}

this.singletons.set(token, construct(provider, this));
this.singletons.set(
token,
providers.map((it) => construct(it, this)),
);
}

return assertNotNull(this.singletons.get(token));
if (options?.multi) {
return assertPresent(this.singletons.get(token));
} else {
return assertPresent(this.singletons.get(token)?.at(0));
}
}

async getAsync<T>(token: Token<T>, options: { multi: true }): Promise<T[]>;
async getAsync<T>(token: Token<T>, options: { optional: true }): Promise<T | undefined>;
async getAsync<T>(token: Token<T>, options: { multi: true; optional: true }): Promise<T[] | undefined>;
async getAsync<T>(token: Token<T>, options?: { optional?: boolean; multi?: boolean }): Promise<T>;
async getAsync<T>(
token: Token<T>,
options: { optional: true },
): Promise<T | undefined>;
async getAsync<T>(
token: Token<T>,
options?: { optional: boolean },
): Promise<T>;
async getAsync<T>(
token: Token<T>,
options?: { optional: boolean },
): Promise<T | undefined> {
options?: {
optional?: boolean;
multi?: boolean;
},
): Promise<T | T[] | undefined> {
this.autoBindIfNeeded(token);

if (!this.providers.has(token)) {
if (options?.optional) {
return Promise.resolve(undefined);
}
throw Error(`No provider found for ${toString(token)}`);
throw Error(`No provider(s) found for ${toString(token)}`);
}

const provider = assertNotNull(this.providers.get(token));
const existingProviders = this.providers.get(token) ?? [];

if (!this.singletons.has(token)) {
const value = await constructAsync(provider, this);
this.singletons.set(token, value);
const values = await Promise.all(existingProviders.map((it) => constructAsync(it, this)));
this.singletons.set(token, values);
}

return await promisify(assertNotNull(this.singletons.get(token)));
if (options?.multi) {
return Promise.all(assertPresent(this.singletons.get(token)).map((it) => promisify(it)));
} else {
return promisify(assertPresent(this.singletons.get(token)?.at(0)));
}
}

private autoBindIfNeeded<T>(token: Token<T>) {
if (!this.providers.has(token)) {
if (isClassToken(token) && isInjectable(token)) {
this.bind({
provide: token,
useClass: getInjectableTarget(token),
});
if (this.singletons.has(token)) {
return;
}

if (isClassToken(token) && isInjectable(token)) {
const targetClasses = getInjectableTargets(token);

// inheritance support: also bind for its super classes
let superClass = Object.getPrototypeOf(token);
while (superClass.name) {
targetClasses
.filter((targetClass) => !this.providers.has(targetClass))
.forEach((targetClass) => {
this.bind({
provide: superClass,
useClass: token,
provide: targetClass,
multi: true,
useClass: targetClass,
});
superClass = Object.getPrototypeOf(superClass)
}
} else if (isInjectionToken(token) && token.options?.factory) {
if (!token.options.async) {
this.bind({
provide: token,
async: false,
useFactory: token.options.factory,
});
} else if (token.options.async) {
});

targetClasses
.filter((it) => it !== token)
.forEach((targetClass) => {
this.bind({
provide: token,
async: true,
useFactory: token.options.factory,
multi: true,
useExisting: targetClass,
});
}
});
} else if (isInjectionToken(token) && token.options?.factory) {
if (!token.options.async) {
this.bind({
provide: token,
async: false,
useFactory: token.options.factory,
});
} else if (token.options.async) {
this.bind({
provide: token,
async: true,
useFactory: token.options.factory,
});
}
}
}
}

let currentScope: Container | undefined = undefined;

export function inject<T>(
token: Token<T>,
options: { optional: true },
): T | undefined;
export function inject<T>(token: Token<T>, options: { optional: true }): T | undefined;
export function inject<T>(token: Token<T>): T;
export function inject<T>(
token: Token<T>,
options?: { optional: boolean },
): T | undefined {
export function inject<T>(token: Token<T>, options?: { optional: boolean }): T | undefined {
if (currentScope === undefined) {
throw new Error("You can only invoke inject() from the injection context");
}
return currentScope.get(token, options);
}

export function injectAsync<T>(
token: Token<T>,
options: { optional: true },
): Promise<T | undefined>;
export function injectAsync<T>(token: Token<T>, options: { optional: true }): Promise<T | undefined>;
export function injectAsync<T>(token: Token<T>): Promise<T>;
export function injectAsync<T>(
token: Token<T>,
options?: { optional: boolean },
): Promise<T | undefined> {
export function injectAsync<T>(token: Token<T>, options?: { optional: boolean }): Promise<T | undefined> {
if (currentScope === undefined) {
throw new Error(
"You can only invoke injectAsync() from the injection context",
);
throw new Error("You can only invoke injectAsync() from the injection context");
}
return currentScope.getAsync(token, options);
}
Expand All @@ -171,10 +203,7 @@ function construct<T>(provider: Provider<T>, scope: Container): Promise<T> | T {
}
}

async function constructAsync<T>(
provider: Provider<T>,
scope: Container,
): Promise<T> {
async function constructAsync<T>(provider: Provider<T>, scope: Container): Promise<T> {
const originalScope = currentScope;
try {
currentScope = scope;
Expand All @@ -189,10 +218,7 @@ async function promisify<T>(value: T | Promise<T>): Promise<T> {
return new Promise<T>((resolve) => resolve(value));
}

function doConstruct<T>(
provider: Provider<T>,
scope: Container,
): T | Promise<T> {
function doConstruct<T>(provider: Provider<T>, scope: Container): T | Promise<T> {
if (isConstructorProvider(provider)) {
return new provider();
} else if (isClassProvider(provider)) {
Expand All @@ -206,14 +232,16 @@ function doConstruct<T>(
}
}

interface ProviderMap extends Map<Token<unknown>, Provider<unknown>> {
get<T>(key: Token<T>): Provider<T> | undefined
set<T>(key: Token<T>, value: Provider<T>): this
interface ProviderMap extends Map<Token<unknown>, Provider<unknown>[]> {
get<T>(key: Token<T>): Provider<T>[] | undefined;

set<T>(key: Token<T>, value: Provider<T>[]): this;
}

interface SingletonMap extends Map<Token<unknown>, unknown> {
get<T>(token: Token<T>): T | undefined
set<T>(token: Token<T>, value: T): this
interface SingletonMap extends Map<Token<unknown>, unknown[]> {
get<T>(token: Token<T>): T[] | undefined;

set<T>(token: Token<T>, value: T[]): this;
}

export function bootstrap<T>(token: Token<T>): T {
Expand All @@ -224,7 +252,7 @@ export function bootstrapAsync<T>(token: Token<T>): Promise<T> {
return new Container().getAsync(token);
}

function assertNotNull<T>(value: T | null | undefined): T {
function assertPresent<T>(value: T | null | undefined): T {
if (value === null || value === undefined) {
throw Error(`Expected value to be not null or undefined`);
}
Expand Down
Loading

0 comments on commit b563a1c

Please sign in to comment.