In this article, we’ll take a look under the hood, and learn how the TypeScript compiler transforms decorators to a native JS code. We’ll focus only on class decorators and see how we can use generated metadata to implement dependency injection.
A Decorator is a special kind of declaration that can be attached to a class declaration, method, accessor, property, or parameter. Decorators use the form
expression must evaluate to a function that will be called at runtime with information about the decorated declaration.
experimentalDecorators compiler option either on the command line or in your
TypeScript includes experimental support for emitting certain types of metadata for declarations that have decorators. To enable this experimental support, you must set the
emitDecoratorMetadata compiler option either on the command line or in your
Some of the guys from the TypeScript team have developed a Polyfill for the prototype of the Reflection API and the TypeScript compiler is now able to emit some serialized design-time type metadata for decorators. We can use this metadata reflection API polyfill by using the reflect-metadata package:
npm install reflect-metadata
We also need to import
reflect-metadata in our
For the moment there are available only three reflect metadata design keys:
- Type metadata uses the metadata key
- Parameter type metadata uses the metadata key
- Return type metadata uses the metadata key
In case you have no idea what DI is, I highly recommend to get in touch with it. Since this post should not be about the What?. but more about the How? let’s try to keep this as simple possible at this point:
Dependency injection is a technique whereby one object supplies the dependencies of another object.
What does that mean? Instead of manually constructing your objects some piece (often called Injector) of your software is responsible for constructing objects.Imagine the following code:
To get an instance of
Example you’d need to construct it the following way:
const example = new Example(new Service1(), new Service2(new Service1()));
By using an Injector, which is responsible for creating objects, you can simply do something like:
const example = Injector.resolve<Example>(Example);
We will implement our very own (and very basic) Injector. In case you’re just looking for some existing solution to get DI in your project you should take a look at InversifyJS, a pretty neat IoC container for TypeScript.
What we’re going to do is we’ll implement our very own Injector, which is able to resolve instances by injecting all necessary dependencies. But how we can know about dependencies of a class at runtime? Well, here comes the
reflect-metadata package. We can read metadata about class constructor by:
It will return
undefined, because TypeScript will not emit metadata about class if it has not been decorated. As in this PR it explicitly was designed to emit metadata just if there is a decorator on a class. So we need a decorator in order our class do emit metadata. Let’s implement:
Let’s have a look at interface
Type<T>: It’s generic type for constructors. Class decorators receive as target argument constructor of class. The
Injectable decorator does nothing but logs metadata
design:paramtypes of class constructor it’s applied to.
Back to our example: after applying
Injectable decorator to
Example class TypeScript will emit metadata about that class:
So now if we call
Reflect.getMetadata('design:paramtypes', Example); // [ [Function: Service1], [Function: Service2] ]
On line 28 we can see that at runtime it adds
design:paramtypes metadata to
Example with value of
[Service1, Service2]. It stores this metadata in WeakMap with key of target and value of array. So that we can know which parameters receives
Example class constructor.
Now lets implement our
Injector which will find out and resolve dependencies of any class.
We have a DI container where will be stored instances of dependencies. Our
resolve method will receive class constructor as parameter, read dependencies’ types and recursively resolve all dependencies. Let’s get back to our (now slightly extended) example at the beginning and resolve it via the
[ [Function: Service1] ]
[ [Function: Service1], [Function: Service2] ]
Injector successfully injected all dependencies. Basically this is how an injector could work. There are still many things to do:
- error handling
- handle circular dependencies
- ability to inject more than constructor tokens
The entire code (including examples and tests) can be found on GitHub.
Sasun Stepanyan is a Senior Software Engineer at ServiceTitan. He’s been with ServiceTitan for a year and began his career in engineering three years ago, quickly demonstrating strong talent in the field and becoming a senior engineer. He loves creative challenges – when he’s not coding, you might find him playing piano.