UNPKG

rsdi

Version:

TypeScript dependency injection container. Strong types without decorators.

267 lines (266 loc) 11.1 kB
import { DenyOverrideDependencyError, DependencyIsMissingError, ForbiddenNameError, } from './errors.js'; // Every public *instance* member, because `addContainerProperty` defines dependencies as own // properties that would otherwise shadow the method of the same name. `export` belongs here even // though it is rarely used directly: `merge` calls it on the containers passed to it, so a // dependency named `export` turned any `merge`/`compose` involving that container into a // `TypeError`. Statics (`compose`) never live on the instance and are deliberately absent. // `src/__tests__/reservedNames.test.ts` fails if a new public method is not listed here. const containerMethods = new Set([ 'add', 'clone', 'export', 'extend', 'get', 'has', 'hasResolvedDependency', 'merge', 'update', ]); /** * Dependency injection container */ export class DIContainer { resolvedDependencies = {}; resolvers = {}; context = {}; constructor() { this.context = new Proxy(this, { get(target, property) { const propertyName = property.toString(); return target[propertyName]; }, }); } /** * Combines independently built containers into a single new container. * * This is the recommended way to wire a large dependency graph. Splitting the graph * into modules and composing them is dramatically cheaper to type-check than one long * `add` chain, because each module chain is type-checked against its own small * resolver map instead of the ever-growing combined one: * * // repositories.ts * export const repositories = new DIContainer().add('userRepository', () => new UserRepository()); * // services.ts * export const services = new DIContainer().add('mailer', () => new Mailer()); * // container.ts * const container = DIContainer.compose(repositories, services); * * Factories may depend on names provided by any of the composed containers — resolution * happens lazily against the composed container, so cross-module dependencies work at * runtime. Only the *types* of a module are limited to what that module declares; when a * module needs another module's dependencies to be visible at compile time, layer them * with `extend` instead. * * The inputs are left untouched: the composed container is a new instance. * * When several containers define the same name the last one wins at runtime, including * over a value the earlier container had already resolved. Note the types intersect rather * than overwrite, so a name defined twice with *different* types resolves to `never` — a * deliberate signal, since a scalable last-writer-wins type fold has to recurse per * container and trips TypeScript's depth limiter at ~50 of them. Prefer `update()` when a * replacement is intentional. * @param containers */ static compose(...containers) { return new DIContainer().merge(...containers); } /** * Adds new dependency resolver to the container. If dependency with given name already exists it will throw an error. * Use update method instead. It will override existing dependency. * @param name * @param resolver */ add(name, resolver) { if (containerMethods.has(name)) { throw new ForbiddenNameError(name); } if (this.has(name)) { throw new DenyOverrideDependencyError(name); } this.setValue(name, resolver); return this; } /** * Creates a new container instance with the same resolvers. * * Useful when you want to share a base container across different modules. * For example, you can define a base container with shared dependencies, * then clone it to create separate DI configurations for different bounded contexts. * * The cloned container is a new instance but retains all the original resolvers. */ clone() { const { resolvedDependencies: newResolvedDependencies, resolvers: newResolvers } = this.export(); // eslint-disable-next-line @typescript-eslint/no-use-before-define const newContainer = new ClonedDiContainer(newResolvers, newResolvedDependencies); return newContainer; } export() { return { resolvedDependencies: this.resolvedDependencies, resolvers: this.resolvers, }; } /** * Extends container with given function. It will pass container as an argument to the function. * Function should return new container with extended resolvers. * It is useful when you want to split your container into multiple files. * You can create a file with resolvers and extend container with it. * You can also use it to create multiple containers with different resolvers. * * For example: * * const container = new DIContainer() * .extend(addValidators) * * export type DIWithValidators = ReturnType<typeof addValidators>; * export const addValidators = (container: DIWithDataAccessors) => { * return container * .add('myValidatorA', ({ a, b, c }) => new MyValidatorA(a, b, c)) * .add('myValidatorB', ({ a, b, c }) => new MyValidatorB(a, b, c)); * }; * @param diConfigurationFactory */ extend(diConfigurationFactory) { return diConfigurationFactory(this); } /** * Resolve dependency by name. Alternatively you can use property access to resolve dependency. * For example: const { a, b } = container; * @param dependencyName */ get(dependencyName) { if (this.resolvedDependencies[dependencyName] !== undefined) { return this.resolvedDependencies[dependencyName]; } const resolver = this.resolvers[dependencyName]; if (!resolver) { throw new DependencyIsMissingError(dependencyName); } this.resolvedDependencies[dependencyName] = resolver(this.context); return this.resolvedDependencies[dependencyName]; } /** * Checks if dependency with given name exists * @param name */ has(name) { return Object.hasOwn(this.resolvers, name); } hasResolvedDependency(name) { return Object.hasOwn(this.resolvedDependencies, name); } /** * Merges other containers into this one. Resolved dependencies are merged as well. * * Accepts any number of containers, so a set of independently built modules can be * combined in a single call: * * base.merge(repositories, services, controllers) * * Combining modules this way is also much cheaper to type-check than one long * `add` chain — see docs/type-performance-plan.md. * * When several containers define the same name the last one wins at runtime, including over * an already-resolved value. The types intersect rather than overwrite, so the same name with * two different types resolves to `never` rather than the later type. * * This mutates and returns `this`; use `clone()` or the static `DIContainer.compose()` * when a separate instance is required. * @param containers */ merge(...containers) { for (const otherContainer of containers) { const { resolvedDependencies: newResolvedDependencies, resolvers: newResolvers } = otherContainer.export(); // A replaced resolver must not keep the value the previous one produced — the same // eviction `update()` performs. Only the overriding container's own cache may survive, // so a name it re-registers without having resolved yet has to lose the old value; // otherwise `merge`/`compose` return the earlier container's instance from a resolver // that no longer exists, silently contradicting last-writer-wins. for (const name of Object.keys(newResolvers)) { if (!Object.hasOwn(newResolvedDependencies, name)) { // eslint-disable-next-line @typescript-eslint/no-dynamic-delete delete this.resolvedDependencies[name]; } } this.resolvers = { ...this.resolvers, ...newResolvers, }; this.resolvedDependencies = { ...this.resolvedDependencies, ...newResolvedDependencies, }; } for (const property of Object.keys(this.resolvers)) { this.addContainerProperty(property); } return this; } /** * Updates existing dependency resolver. If dependency with given name does not exist it will throw an error. * In most cases you don't need to override dependencies and should use add method instead. This approach will * help you to avoid overriding dependencies by mistake. * * You may want to override dependency if you want to mock it in tests. * @param name * @param resolver */ update(name, resolver) { if (containerMethods.has(name)) { throw new ForbiddenNameError(name); } if (!this.has(name)) { throw new DependencyIsMissingError(name); } this.setValue(name, resolver); if (Object.hasOwn(this.resolvedDependencies, name)) { // eslint-disable-next-line @typescript-eslint/no-dynamic-delete delete this.resolvedDependencies[name]; } return this; } setResolvers(resolvers, resolvedDependencies) { if (Object.keys(this.resolvers).length !== 0) { throw new Error('Cannot set resolved dependencies after resolvers are defined'); } // @ts-expect-error - we are setting resolvers this.resolvers = resolvers; // @ts-expect-error - we are setting resolvedDependencies this.resolvedDependencies = { ...resolvedDependencies, }; for (const property of Object.keys(this.resolvers)) { this.addContainerProperty(property); } } addContainerProperty(name) { // eslint-disable-next-line unicorn/no-this-assignment, @typescript-eslint/no-this-alias, consistent-this let updatedObject = this; if (!Object.hasOwn(this, name)) { updatedObject = Object.defineProperty(this, name, { get() { return this.get(name); }, }); } return updatedObject; } /** * Sets value to the container */ setValue(name, resolver) { this.resolvers = { ...this.resolvers, [name]: resolver, }; this.addContainerProperty(name); } } class ClonedDiContainer extends DIContainer { constructor(resolvers, resolvedDependencies) { super(); this.setResolvers(resolvers, resolvedDependencies); } }