144 lines
4.7 KiB
TypeScript
Executable File
144 lines
4.7 KiB
TypeScript
Executable File
import 'reflect-metadata';
|
|
|
|
/**
|
|
* Represents a class constructor type that can be instantiated with 'new'
|
|
* @template T The type of instance the constructor creates
|
|
*/
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
export type Constructable<T = unknown> = new (...args: any[]) => T;
|
|
|
|
type AbstractConstructable<T = unknown> = abstract new (...args: unknown[]) => T;
|
|
|
|
type ServiceIdentifier<T = unknown> = Constructable<T> | AbstractConstructable<T>;
|
|
|
|
type Factory<T = unknown> = (...args: unknown[]) => T;
|
|
|
|
interface Metadata<T = unknown> {
|
|
instance?: T;
|
|
factory?: Factory<T>;
|
|
}
|
|
|
|
interface Options<T> {
|
|
factory?: Factory<T>;
|
|
}
|
|
|
|
const instances = new Map<ServiceIdentifier, Metadata>();
|
|
|
|
/**
|
|
* Decorator that marks a class as available for dependency injection.
|
|
* @param options Configuration options for the injectable class
|
|
* @param options.factory Optional factory function to create instances of this class
|
|
* @returns A class decorator to be applied to the target class
|
|
*/
|
|
// eslint-disable-next-line @typescript-eslint/no-restricted-types
|
|
export function Service<T = unknown>(): Function;
|
|
// eslint-disable-next-line @typescript-eslint/no-restricted-types
|
|
export function Service<T = unknown>(options: Options<T>): Function;
|
|
export function Service<T>({ factory }: Options<T> = {}) {
|
|
return function (target: Constructable<T>) {
|
|
instances.set(target, { factory });
|
|
return target;
|
|
};
|
|
}
|
|
|
|
class DIError extends Error {
|
|
constructor(message: string) {
|
|
super(`[DI] ${message}`);
|
|
}
|
|
}
|
|
|
|
class ContainerClass {
|
|
/** Stack to track types being resolved to detect circular dependencies */
|
|
private readonly resolutionStack: ServiceIdentifier[] = [];
|
|
|
|
/**
|
|
* Checks if a type is registered in the container
|
|
* @template T The type to check for
|
|
* @param type The constructor of the type to check
|
|
* @returns True if the type is registered (has metadata), false otherwise
|
|
*/
|
|
has<T>(type: ServiceIdentifier<T>): boolean {
|
|
return instances.has(type);
|
|
}
|
|
|
|
/**
|
|
* Retrieves or creates an instance of the specified type from the container
|
|
* @template T The type of instance to retrieve
|
|
* @param type The constructor of the type to retrieve
|
|
* @returns An instance of the specified type with all dependencies injected
|
|
* @throws {DIError} If circular dependencies are detected or if the type is not injectable
|
|
*/
|
|
get<T>(type: ServiceIdentifier<T>): T {
|
|
const { resolutionStack } = this;
|
|
const metadata = instances.get(type) as Metadata<T>;
|
|
if (!metadata) {
|
|
// Special case: Allow undefined returns for non-decorated constructor params
|
|
// when resolving a dependency chain (i.e., resolutionStack not empty)
|
|
if (resolutionStack.length) return undefined as T;
|
|
throw new DIError(`${type.name} is not decorated with ${Service.name}`);
|
|
}
|
|
|
|
if (metadata?.instance) return metadata.instance as T;
|
|
|
|
// Add current type to resolution stack before resolving dependencies
|
|
resolutionStack.push(type);
|
|
|
|
try {
|
|
let instance: T;
|
|
|
|
const paramTypes = (Reflect.getMetadata('design:paramtypes', type) ?? []) as Constructable[];
|
|
|
|
const dependencies = paramTypes.map(<P>(paramType: Constructable<P>, index: number) => {
|
|
if (paramType === undefined) {
|
|
throw new DIError(
|
|
`Circular dependency detected in ${type.name} at index ${index}.\n${resolutionStack.map((t) => t.name).join(' -> ')}\n`,
|
|
);
|
|
}
|
|
return this.get(paramType);
|
|
});
|
|
|
|
if (metadata?.factory) {
|
|
instance = metadata.factory(...dependencies);
|
|
} else {
|
|
// Create new instance with resolved dependencies
|
|
instance = new (type as Constructable)(...dependencies) as T;
|
|
}
|
|
|
|
instances.set(type, { ...metadata, instance });
|
|
return instance;
|
|
} catch (error) {
|
|
if (error instanceof TypeError && error.message.toLowerCase().includes('abstract')) {
|
|
throw new DIError(`${type.name} is an abstract class, and cannot be instantiated`);
|
|
}
|
|
throw error;
|
|
} finally {
|
|
resolutionStack.pop();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Manually sets an instance for a specific type in the container
|
|
* @template T The type of instance being set
|
|
* @param type The constructor of the type to set. This can also be an abstract class
|
|
* @param instance The instance to store in the container
|
|
*/
|
|
set<T>(type: ServiceIdentifier<T>, instance: T): void {
|
|
// Preserve any existing metadata (like factory) when setting new instance
|
|
const metadata = instances.get(type) ?? {};
|
|
instances.set(type, { ...metadata, instance });
|
|
}
|
|
|
|
/** Clears all instantiated instances from the container while preserving type registrations */
|
|
reset(): void {
|
|
for (const metadata of instances.values()) {
|
|
delete metadata.instance;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Global dependency injection container instance
|
|
* Used to retrieve and manage class instances and their dependencies
|
|
*/
|
|
export const Container = new ContainerClass();
|