Dependency injection
The package provides very basic but powerful utilities to implement DI in your projects.
Basics
Inversion of control (IoC) - is a set of patterns that make defining dependencies in your projects easier.
One of such patterns is making DI container - object that can be used to register components of your system and automatically resolves dependencies between them. When you take a component out from container, it automatically gets all the required dependencies.
For example your application has http client and it depends on logger, it logs every request and response info to console or Sentry or something else. You just resolves client from container and uses instance that automatically created inside container and takes all dependencies.
DI in JavaScript context
Classically DI containers are implemented over popular feature that many languages support - reflections. In a few words, reflections - is an ability in runtime to get information like
- "what is type of argument of this function?";
- "what type of return value of this function?"
- etc.
In JS we don't have this feature. Maximum that we have is typeof operator but this is not enough.
Good news is that reflections is not strictly required to achieve DI container features.
Reflections vs identifiers
In JS we have no type annotations =) (we have it in TS but more on that later). Annotations is needed to detect dependencies. So what if we use just strings? Let's bind components in container by string identifiers, not interfaces.
We can implement it and use for example like this:
import { Container } from '@my-org/ioc';
import { MyCustomLogger } from '#logger';
const container = new Container();
container.register('Logger', MyCustomLogger);
// ...later, instead this:
const logger = new Logger(config.appName, config.appVersion);
logger.transport = new Transport(config.bufferSize);
// you just do this:
const logger = container.get('Logger');
logger.info('App started...');
Under the hood of Container in this example you need to just return component from hash table.
Identifiers and TypeScript
The problem of previous example is that we can't attach some type or interface to string identifier.
Technically we of course can do it but we need something like builder pattern:
import { Container } from '@my-org/ioc';
export const container = new Container()
.add<Logger>('Logger', MyLogger)
.add<HttpClient>('Client', MyClient);
const logger = container.get('Logger'); // will have type Logger
This example will work but this is very inconvenient: you will have to import this particular container everywhere where you want to get a component from it. So, it more looks like "Service locator", but not "DI container"
Service locators is almost like DI containers, but with one problem - they exist as global variables in your project. Generally, this is not always a bad pattern. For example, in game development, this is a common approach that works quite well.
This library provides ability to define exactly DI containers without without the need to create global variables.
Extending identifiers
Since we can't bind a type to a string identifier, we need something else. What if we use an object instead of a primitive? We can easily attach a specific type to objects in TypeScript.
We'll replace the concept of an "identifier" with the concept of a "token". Token - is an object that can be used as an identifier and to which a specific interface can be assigned.
In TypeScript we can bind types via generics and thats why tokens are so useful abstraction.