Skip to main content

Documentation for @ts-stack/di

Install

yarn add @ts-stack/di reflect-metadata

How Dependency Injection works

Consider the following situation:

class Service1 {}

class Service2 {
constructor(service1: Service1) {}
}

class Service3 {
constructor(service2: Service2) {}
}

const service1 = new Service1();
const service2 = new Service2(service1);
const service3 = new Service3(service2);

To get an instance of the Service3 class, you need to know not only that it depends on Service2, but also that Service2 depends on Service1. It is clear that in real applications there are many more classes, and the connections between them will be much more difficult to trace.

The pattern "Dependency Injection" (abbreviated - DI) greatly simplifies work in such situations. One of the implementations of this pattern is implemented in the library @ts-stack/di. This library is actually an excerpt from Angular v4.4.7, but it can be used in any TypeScript project because it no longer does anything specific for Angular. Let's use it for our task:

import 'reflect-metadata';
import { ReflectiveInjector, injectable } from '@ts-stack/di';

class Service1 {}

@injectable()
class Service2 {
constructor(service1: Service1) {}
}

@injectable()
class Service3 {
constructor(service2: Service2) {}
}

const injector = ReflectiveInjector.resolveAndCreate([Service1, Service2, Service3]);
const service3 = injector.get(Service3);

The ReflectiveInjector.resolveAndCreate() method takes an array of classes at the input and outputs a specific object called an injector. This injector obviously contains the transferred classes, and is able to create their instances, considering all chain of dependencies (Service3 -> Service2 -> Service1).

That is, the work of the injector is that when it is asked Service3, it looks at the constructor of this class, sees the dependence on Service2, then sees its constructor, sees the dependence on Service1, looks at its constructor, does not find there dependencies, and therefore creates the first - instance Service1. Once you have the Service1 instance, you can create the Service2 instance, and once you've done that, you can finally create the Service3 instance.

In this case, you may not know the whole chain of dependencies Service3, entrust this work to the injector, the main thing - give to its array all the necessary classes.

Prerequisites for @ts-stack/di

From the point of view of the JavaScript developer, the fact that DI can somehow view class constructors and see there other classes - this can be called magic. And this magic is provided by the following necessary prerequisites of work of this library:

  1. In your project, in the file tsconfig.json it is necessary to allow to use decorators:
{
"compilerOptions": {
// ...
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}
  1. Install and import reflect-metadata to collect metadata from any decorator, and to attach this metadata to each class. You may not remember what exactly reflect-metadata does, it is enough to know that such import is necessary when working with decorators.
  2. You must also use the @injectable() decorator above each class that has dependencies. Thanks to this decorator, DI collects metadata from class constructors, and therefore knows how many parameters each constructor has, and what types of these parameters.

If the last two conditions are not met, you will receive approximately the following error:

Cannot resolve all parameters for 'Service2'(?). Make sure that all the parameters are decorated with inject or have valid type annotations and that 'Service2' is decorated with injectable.

When DI creates instances

Let's take a closer look at the injectors mentioned above. From the previous example it is clear that the injector contains an array of classes transferred to it and it knows how to make their instances. But here are a few more important unobvious points. Let's change a little example:

import 'reflect-metadata';
import { ReflectiveInjector, injectable } from '@ts-stack/di';

class Service1 {}

class Service2 {}

@injectable()
class Service3 {
constructor(service2: Service2) {}
}

const injector = ReflectiveInjector.resolveAndCreate([Service1, Service2, Service3]);
const service3 = injector.get(Service3);

service3 === injector.get(Service3); // true

Now Service2 is not dependent on Service1, and when creating an instance of Service3, the injector will also create an instance of class Service2, but will not create an instance of class Service1, because it has not been requested and does not depend on it other classes. On the other hand, all already created instances will be stored in the injector itself and returned upon repeated requests. That is, a specific injector creates an instance of a specific class using injector.get() only once, but only after that instance is requested.

It turns out that if you need to make instances of certain classes more often using injector.get(), you need to create new injectors:

import { ReflectiveInjector } from '@ts-stack/di';

class Service1 {}

class Service2 {}

const services = [Service1, Service2];

const injector1 = ReflectiveInjector.resolveAndCreate(services);
const injector2 = ReflectiveInjector.resolveAndCreate(services);

injector1.get(Service2) === injector2.get(Service2); // false

There is another way to get a new instance of a certain class each time:

//..

injector1.resolveAndInstantiate(Service2) === injector1.resolveAndInstantiate(Service2); // false

Hierarchy of injectors

The @ts-stack/di library also allows you to create a hierarchy of injectors - this is when there are parent and child injectors. At first glance, there is nothing interesting in such a hierarchy, because it is not clear why it is needed, but in practice this feature is used very often, because it allows you to make the application architecture modular. Special attention should be paid to the study of the specifics of the hierarchy, it will save you a lot of time in the future, because you will know how it works and why it does not find this dependence...

When creating a hierarchy, the connection is held only by the child injector, it has the object of the parent injector. At the same time, the parent injector knows nothing about its child injectors. That is, the connection between the injectors is one-way. Conventionally, it looks like this:

interface Parent {
// There are certain properties of the parent injector, but no child injector
}

interface Child {
parent: Parent;
// There are other properties of the child injector.
}

Due to the presence of the parent injector object, the child injector may contact the parent injector when asked for an instance of a class that it does not have.

Let's consider the following example. For simplicity, decorators are not used here at all, as each class is independent:

import { ReflectiveInjector } from '@ts-stack/di';

class Service1 {}
class Service2 {}
class Service3 {}
class Service4 {}

const parent = ReflectiveInjector.resolveAndCreate([Service1, Service2]); // Parent injector
const child = parent.resolveAndCreateChild([Service2, Service3]); // Child injector

child.get(Service1); // ОК
parent.get(Service1); // ОК

parent.get(Service1) === child.get(Service1); // true

child.get(Service2); // ОК
parent.get(Service2); // ОК

parent.get(Service2) === child.get(Service2); // false

child.get(Service3); // ОК
parent.get(Service3); // Error - No provider for Service3!

child.get(Service4); // Error - No provider for Service4!
parent.get(Service4); // Error - No provider for Service4!

As you can see, when creating a child injector, it was not given Service1, so when you request an instance of this class, it will contact its parent. By the way, there is one unobvious but very important point here: although the child injectors ask the parent injectors for certain instances of the classes, they do not create them on their own. That is why this expression returns true:

parent.get(Service1) === child.get(Service1); // true

And Service2 has both injectors, so each of them will create its own local version, and that's why this expression returns false:

parent.get(Service2) === child.get(Service2); // false

The parent injector cannot create an instance of the Service3 class because the parent injector has no connection to the child injector that has Service3.

Well, both injectors can't create a Service4 instance because they weren't given this class when they were created.

DI tokens, providers and substitution providers

When you query another class in the class constructor, the DI actually remembers that other class as a token to find the desired value in the injector array. That is, the token is the identifier used to search iside an injector.

Not only classes but also objects can be transferred to the injector array:

const injector = ReflectiveInjector.resolveAndCreate([{ token: Service1, useClass: Service2 }]);

const service = injector.get(Service1); // instance of Service2

So we write instructions for DI: "When the injector is asked for a token Service1, actually need to return an instance of class Service2". This instruction essentially replaces the so-called "provider".

The term provider in @ts-stack/di means either a class or an object with the following possible properties:

{ token: <token>, useClass: <class> },
{ token: <token>, useValue: <any value> },
{ token: <token>, useFactory: [<Class>, <Class.prototype.methodName>] },
{ token: <token>, useToken: <another token> },

Every provider has a token, but not every token can be a provider. In fact, only a class can act as both a provider and a token. For example, a string can only be used as a token, not as a provider. Token types are described in more detail in the next section.

There is also the concept of multi-providers, but they will be mentioned later.

useToken

As shown in the previous example, to specify a provider, you can use an object with the useToken property. Note that in this case you are not passing the provider itself, but only pointing to its token. Example:

[
{ token: Class2, useToken: Class1 },
// ...
]

Here, the token Class2 points to another token Class1. For the DI injector, this instruction says: "To find the value for the token Class2, need to search for the provider by the token Class1."

When is it needed?

This option is useful when you have a base class and an extended class, and you want to use the base class as a token for DI, and an instance of the extended class as the value for that token. However, you want to use the base class interface in some cases and the extended class interface in others.

An example from real life. Let's say your framework uses a basic logger that accepts a basic configuration via DI:

class BaseLoggerConfig {
level: string;
}

You want to extend this configuration to work for both the basic and extended loggers:

class ExtendedLoggerConfig extends BaseLoggerConfig {
displayFilePath: string;
displayFunctionName: boolean;
}

However, you want to use the basic configuration interface in the basic logger, and the extended configuration interface in the extended one:

// Somewhere in your framework code
class BaseLogger {
constructor(private loggerConfig: BaseLoggerConfig) {}
}

//...

// Somewhere in your application code
class ExtendedLogger extends BaseLogger {
constructor(private extendedLoggerConfig: ExtendedLoggerConfig) {
super(extendedLoggerConfig);
// ...
}
}

To avoid passing two different configurations to DI, you can use useToken:

[
{ token: BaseLoggerConfig, useValue: new ExtendedLoggerConfig() },
{ token: ExtendedLoggerConfig, useToken: BaseLoggerConfig },
]

This way you pass two instructions to DI:

  1. the first element in the array transfers the value for the BaseLoggerConfig token;
  2. the second element in the array indicates that the value of the ExtendedLoggerConfig token should be searched for by the BaseLoggerConfig token (that is, it actually points to the first element of the array).

In this case, both the basic and the extended logger will receive the same extended configuration, which will be compatible with the basic configuration.

Multiple addition of providers with the same token

You can pass many providers to the injector array for the same token, but DI will choose the last provider:

import { ReflectiveInjector } from '@ts-stack/di';

class Service1 {}
class Service2 {}
class Service3 {}

const injector = ReflectiveInjector.resolveAndCreate([
Service1,
{ token: Service1, useClass: Service2 },
{ token: Service1, useClass: Service3 },
]);

injector.get(Service1); // instance of Service3

Here, three providers are transferred to the injector for the Service1 token, but DI will choose the last one, so an instance of the Service3 class will be created.

In practice, thanks to this mechanism, developers of frameworks can transfer default providers to the injector, and users of these frameworks can substitute them with their own providers. This mechanism also simplifies application testing, as some providers can be transmitted in the application itself and others in tests.

Types of DI tokens

The token type can be either a class, or an object, or text, or symbol. Interfaces or types declared with the type keyword cannot be used as tokens, because once they are compiled from TypeScript into JavaScript, nothing will be left of them in JavaScript files. Also, you can't use arrays as a token, because TypeScript doesn't have a mechanism to pass an array type to compiled JavaScript code.

However, in the constructor as a token it is easiest to specify the class, otherwise, you must use the decorator inject. For example, you can use string tokenForLocal as a token:

import { injectable, inject, ReflectiveInjector } from '@ts-stack/di';

@injectable()
export class Service1 {
constructor(@inject('tokenForLocal') local: string) {}
}

const injector = ReflectiveInjector.resolveAndCreate([{ token: 'tokenForLocal', useValue: 'uk' }]);

injector.get(Service1); // OK

InjectionToken

In addition to the ability to use tokens that have different types of data, DI has a special class recommended for creating tokens - InjectionToken. Because it has a parameter for the type (it's generic), you can read the data type that will return the DI when requesting a specific token:

import { InjectionToken } from '@ts-stack/di';

export const LOCAL = new InjectionToken<string>('tokenForLocal');

It can be used in the same way as all other tokens that are not classes:

import { injectable, inject, ReflectiveInjector } from '@ts-stack/di';

import { LOCAL } from './tokens';

@injectable()
export class Service1 {
constructor(@inject(LOCAL) local: string) {}
}

const injector = ReflectiveInjector.resolveAndCreate([{ token: LOCAL, useValue: 'uk' }]);

injector.get(Service1); // ОК

Of course, it is recommended to use the InjectionToken only if you cannot use a certain class directly as a token.

Multi providers

This type of providers differs from regular DI providers by the presence of the multi: true property. Such providers are advisable to use when there is a need to transfer several providers with the same token to DI at once, so that DI returns the same number of values for these providers in one array:

import { ReflectiveInjector } from '@ts-stack/di';

import { LOCAL } from './tokens';

const injector = ReflectiveInjector.resolveAndCreate([
{ token: LOCAL, useValue: 'uk', multi: true },
{ token: LOCAL, useValue: 'en', multi: true },
]);

const locals = injector.get(LOCAL); // ['uk', 'en']

It is not allowed that both regular and multi providers have the same token in one injector:

import { ReflectiveInjector } from '@ts-stack/di';

import { LOCAL } from './tokens';

const injector = ReflectiveInjector.resolveAndCreate([
{ token: LOCAL, useValue: 'uk' },
{ token: LOCAL, useValue: 'en', multi: true },
]);

const locals = injector.get(LOCAL); // Error: Cannot mix multi providers and regular providers

Child injectors may return multi providers of the parent injector only if they did not receive providers with the same tokens when creating child injectors:

import { ReflectiveInjector } from '@ts-stack/di';

import { LOCAL } from './tokens';

const parent = ReflectiveInjector.resolveAndCreate([
{ token: LOCAL, useValue: 'uk', multi: true },
{ token: LOCAL, useValue: 'en', multi: true },
]);

const child = parent.resolveAndCreateChild([]);

const locals = child.get(LOCAL); // ['uk', 'en']

If both the child injector and the parent injector have multi providers with the same token, the child injector will return values only from its array:

import { ReflectiveInjector } from '@ts-stack/di';

import { LOCAL } from './tokens';

const parent = ReflectiveInjector.resolveAndCreate([
{ token: LOCAL, useValue: 'uk', multi: true },
{ token: LOCAL, useValue: 'en', multi: true },
]);

const child = parent.resolveAndCreateChild([
{ token: LOCAL, useValue: 'аа', multi: true }
]);

const locals = child.get(LOCAL); // ['аа']

Substituting multiproviders

To make it possible to substituting a specific multiprovider, you can do the following:

  1. first pass the multiprovider and use the useToken property;
  2. then transfer the class you want to substituting;
  3. and at the end of the array, pass the class that substituting the class you need.
import { ReflectiveInjector } from '@ts-stack/di';

import { HTTP_INTERCEPTORS } from './constants';
import { DefaultInterceptor } from './default.interceptor';
import { MyInterceptor } from './my.interceptor';

const injector = ReflectiveInjector.resolveAndCreate([
{ token: HTTP_INTERCEPTORS, useToken: DefaultInterceptor, multi: true },
DefaultInterceptor,
{ token: DefaultInterceptor, useClass: MyInterceptor }
]);

const locals = injector.get(HTTP_INTERCEPTORS); // [MyInterceptor]

This construction makes sense, for example, if the first two points are performed somewhere in an external module to which you do not have access to edit, and the third point is performed by the user of this module.