rsdi
Version:
TypeScript dependency injection container. Strong types without decorators.
120 lines (119 loc) • 4.18 kB
JavaScript
import { DenyOverrideDependencyError, DependencyIsMissingError, ForbiddenNameError, IncorrectInvocationError, } from "./errors.js";
const containerMethods = ["add", "get", "extend", "update"];
/**
* Dependency injection container
*/
export class DIContainer {
resolvers = {};
resolvedDependencies = {};
context = {};
/**
* 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.includes(name)) {
throw new ForbiddenNameError(name);
}
if (this.has(name)) {
throw new DenyOverrideDependencyError(name);
}
return this.setValue(name, resolver);
}
/**
* 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.includes(name)) {
throw new ForbiddenNameError(name);
}
if (!this.has(name)) {
throw new DependencyIsMissingError(name);
}
return this.setValue(name, resolver);
}
/**
* Checks if dependency with given name exists
* @param name
*/
has(name) {
return this.resolvers.hasOwnProperty(name);
}
/**
* 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];
}
/**
* 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 f
*/
extend(f) {
return f(this.toContainer());
}
setValue(name, resolver) {
this.resolvers = {
...this.resolvers,
[name]: resolver,
};
let updatedObject = this;
if (!this.hasOwnProperty(name)) {
updatedObject = Object.defineProperty(this, name, {
get() {
return this.get(name);
},
});
}
this.context = new Proxy(this, {
get(target, property) {
if (containerMethods.includes(property.toString())) {
throw new IncorrectInvocationError();
}
// @ts-ignore
return target[property];
},
});
return updatedObject;
}
toContainer() {
return this;
}
}