TypeScript Dependency Injection using ES Decorators
This post shows how to create a dependency injection (DI) mini-framework using the new ES decorators.
Most TypeScript DI frameworks use the legacy TypeScript experimental decorators. This feature likely won’t leave the experimental stage. TypeScript now uses the ES decorator standard by default.
Now is the time to use the new standard. esbuild
recently added decorator support, so if your project uses esbuild
for bundling, you can use decorators.
What are decorators?
A decorator is a @
prefixed function call that can enhance classes.
This is the first example in the specification.
@defineElement("my-class")
class C extends HTMLElement {
@reactive accessor clicked = false;
}
The key thing is that decorators are plain functions called by the runtime with arguments based on what part of the class they decorate.
The defineElement
from above is a function that returns a function with this signature:
type ClassDecorator = (
value: Function,
context: {
kind: 'class';
name: string | undefined;
addInitializer(initializer: () => void): void;
}
) => Function | void;
The implementation could look like:
const defineElement = (name) => (value, context) => {
console.log(`I decorated class ${context.name} with ${name}`);
};
And at runtime, you’d see the I decorated class C with my-class
log message.
To play with decorators locally, you can install esbuild
or check out this setup project.
See the References section for more resources to understand the standard better.
Designing the @Injectable
decorator
Now that we covered decorators, we can design the DI mini-framework. Our requirements are:
- We should be able to decorate any class without changing it.
- The decorator captures the class’ interface and dependencies.
- The decorator must be type-safe. If the decorator types don’t match the class types, the compiler must fail.
- The DI container initializes the decorated classes so the class’ dependencies are initialized first.
The trickiest requirement is type-safety. That will need some TypeScript type sorcery.
Branding types
TypeScript removes all type information and produces plain JavaScript. esbuild
does the same. So we need a way to preserve the type information past compilation. A commonly used mechanism is called branded types.
Let’s start with an example interface.
interface A {
field: string;
}
The A
interface is only used for type-checking and is removed at runtime.
But we want to use it at runtime like this:
@Injectable(A)
class AImpl implements A {
field = 'value';
}
and
const a = container.get(A);
How do we keep A
at runtime? We’ll brand it.
export type InterfaceId<T> = string & { __type: T };
TypeScript sorcery exhibit A: Branding
The union with { __type: T}
is an arbitrary way to use the type; otherwise, the compiler would fail.
The good news is we don’t have to add __type
to every interface ID; we can use type assertion:
const A = 'A' as InterfaceId<A>;
Or we can make a helper for it:
export const createInterfaceId = <T>(id: string): InterfaceId<T> => id as InterfaceId<T>;
Now we can create the A
branded type, and the previous examples of decorating the class and getting the instance from the container will work.
Decorating a class with its interface
The decorator standard lets us decorate 4 class elements:
- Class itself
- Methods
- Fields
- Accessors
Looking at requirement 2:
The decorator captures the class’ interface and dependencies.
We need a way to add this information. The DI framework needs to know the class implements an interface (remember, type information is removed at compile time), and we need to express that the class needs dependencies to initialize.
We also must decide how the dependencies will get to the class. I think there’s only one option: pass them to the constructor. In TypeScript, you can define that a class property is mandatory only if you initialize it in the constructor. Also, any other solution would clash with our first requirement:
We should be able to decorate any class without changing it.
Looking at existing frameworks like tsyringe
(virtually all use the TS experimental decorators), they would decorate a class like this:
@Injectable(ConfigService)
class DefaultConfigService implements ConfigService {
constructor(@Inject(DbService) dbService: DbService){}
}
This is elegant, and the decorators are in the right place, close to the affected code.
However, the ES decorator standard doesn’t support constructor or method parameter decorators.
So the only place left is the class decorator. It will have to capture both the class’ interface and its dependencies.
I ended up with this API:
@Injectable(B, [A])
class BImpl implements B {
constructor(a: A) {}
}
We use a single @Injectable
decorator and give it two arguments: the implemented interface and all dependencies.
Now the hard part: how to make @Injectable
type-safe?
Making @Injectable
type-safe
Type-safe interface
Let’s start with the easier part. Remember that a class decorator is a function that accepts the decorated class as a parameter. And thanks to the type system, we can define a decorator that will only accept a class implementing some interface.
The simplest way would be:
interface Dog {
bark(): string;
}
const Dog = createInterfaceId<Dog>("Dog");
function dogDecorator<T extends { new (...args: any): Dog }>(
constructor: T,
{ kind }: ClassDecoratorContext,
) {}
@dogDecorator
class Corgi implements Dog {
bark = () => 'woof';
}
To understand the decorator, first see what the class type is:
type Class = { new (...args: any[]): any }
TypeScript sorcery exhibit B: Class type
Since classes in JS are constructor functions, that’s what the type says: a class is a constructor function that takes arguments and makes an instance.
Knowing about the constructor type, you can read the dogDecorator
function signature better:
We reduce the type set that can be decorated with dogDecorator
to classes whose instances implement the Dog
interface.
If we generalize this, we get the first building block of the @Injectable
interface:
export function Injectable<I>(
id: InterfaceId<I>,
) {
return function <T extends { new (...args: any): I }>(
constructor: T,
{ kind }: ClassDecoratorContext,
) {
// the business logic goes here
};
}
Now we can decorate a class with an interface in a type-safe way:
@Injectable(A) // compiler fails if AImpl doesn't implement A
class AImpl implements A {
field = 'value';
}
Type-safe dependencies
This section is the hardest to grasp but also the most important part of the framework we’re building.
We need to ensure that the second argument to @Injectable
has identical types and order as the class constructor.
@Injectable(A, [X, Y, Z])
class AImpl implements A {
constructor(x: X, y: Y, z: Z) {}
}
In the example above, we have two types:
- the
@Injectable
second parameter has type[InterfaceId<X>, InterfaceId<Y>, InterfaceId<Z>]
- the constructor parameters:
...[X, Y, Z]
How do we verify that the InterfaceIds are wrapping the same types and have the same order as constructor parameters?
infer
to the rescue
The Inferring Within Conditional Types part of the TypeScript documentation is an interesting read that I recommend.
The TL;DR is that with infer
, you can introduce a new generic type in the middle of a type declaration, and based on the inference, you branch the type. You can use infer
only in a conditional type.
This is how we unwrap the array of InterfaceId
s:
type UnwrapInterfaceIds<T extends InterfaceId<unknown>[]> = {
[K in keyof T]: T[K] extends InterfaceId<infer U> ? U : never;
};
TypeScript sorcery exhibit B: using infer
in conditional types
We say:
- Our type accepts generic parameter
T
, which is an array ofInterfaceId
s. - For all keys (indexes in this context), we try to infer the inner type
U
. - If we don’t succeed, we return the
never
type. In other words, this branch doesn’t happen.
Putting it together
Now, with UnwrapInterfaceIds
, we can define the final type of the @Injectable
decorator:
export function Injectable<I, TDependencies extends InterfaceId<unknown>[]>(
id: InterfaceId<I>,
dependencies: [...TDependencies],
) {
return function <T extends { new (...args: UnwrapInterfaceIds<TDependencies>): I }>(
constructor: T,
{ kind }: ClassDecoratorContext,
) {
// the business logic goes here
};
}
The key addition is adding the TDependencies
generic and ensuring that the decorated function has a constructor with parameters with order and type equal to unwrapped TDependencies
using ...args: UnwrapInterfaceIds<TDependencies>
.
This means that our @Injectable
decorator will only work on classes that implement the interface and have all the constructor parameters typed as the defined dependencies. Yay 🎉
Congratulations, you read through the hardest part. I hope I wrote it clearly; if not, chuck the snippets with my explanation to your favourite chatbot and let it rephrase it.
Initializing Container
The types are done. Now we just need to write the “business logic” as I called it before.
This is the easy part.
Our logic has three steps:
- Decoration
- We need to tell our DI that a class implements an interface and needs some dependencies
@Injectable
implementation does this
- Initialization
- Once we know about all classes and dependencies, we need to initialize them in the right order
Container.init
will do this
- Retrieval
- We need access to initialized instances in the container
Container.get
will do this
What is this Container
you ask? It’s a class we make responsible for managing the classes and their instances.
Decoration
We need @Injectable
to do something. More concretely, we need it to pass the interface ID and list of dependencies (I call those metadata from now on) to the Container
.
But how?
Originally, my idea was to add the metadata as “private attributes” on the class (prototype). Something like this:
// omitting all the types for brevity
function Injectable(id, dependencies) {
return function(constructor) {
constructor.__id = id;
constructor.__dependencies = dependencies;
};
}
This worked, but it changes the class, which is not great. My colleague Paul suggested a better way:
interface Metadata {
id: string;
dependencies: string[];
}
const injectableMetadata = new WeakMap<object, Metadata>();
// omitting all the types for brevity
function Injectable(id, dependencies) {
return function(constructor) {
injectableMetadata.set(constructor, {id, dependencies});
};
}
We create a global WeakMap
and store the metadata for each class in that map. Now we are ready to start initializing the classes.
Initialization
To initialize the container, we need to know two things:
- All classes that should be initialized
- Metadata for all these classes
The list of classes will be an argument to the Container.init
function. The metadata should all be in the injectableMetadata
weak map.
type ClassWithDependencies = { cls: Class; id: string; dependencies: string[] };
class Container {
#instances = new Map<string, unknown>();
init(...classes: Class[]) {
// validate classes
// Create instances in topological order
for (const cwd of sortDependencies(classes)) {
const args = cwd.dependencies.map((dependencyId) => this.#instances.get(dependencyId));
const instance = new cwd.cls(...args);
this.#instances.set(cwd.id, instance);
}
}
}
In the code above, you see that we pass a list of classes to Container.init
, we ensure that we can initialize the classes, and then we sort them. When we have them sorted, we start initializing them in order from least to most dependent.
I skipped over the sort; see the Exercises left to the reader for an implementation link.
Retrieval
Once we initialized the Container
, we might have to retrieve instances from it. We implement a simple Container.get
method:
export class Container {
#instances = new Map<string, unknown>();
get<T>(id: InterfaceId<T>): T {
if (!this.#instances.has(id)) {
throw new Error();
}
return this.#instances.get(id) as T;
}
And with this, we explained all concepts needed for a fully functional, type-safe dependency injection mini-framework 🚀.
Exercises left to the reader
If you add up the code above, it won’t compile or run. There were several things I omitted because I didn’t consider them too complex and wanted to keep this post reasonably long. You can check the full implementation here.
This is a list of things that I didn’t explain:
- Adding pre-initialized instances
- In addition to calling
Container.init
, you might want to add a method that will add existing instances to the container. Adding pre-initialized instances is useful if the instances have constructor parameters that the framework can’t provide (e.g., database connection). - See the
Container.addInstance()
implementation.
- In addition to calling
- Topological sort
- When classes depend on each other, you have to initialize them in such an order that all dependencies of a class are created before we call the class constructor.
- See the code here.
- Runtime validation - the type system can’t ensure the container is initialized correctly, so we need to implement these runtime validations:
Conclusion
I’m happy that I learned more about decorators. They used to seem like magic, and now I (and hopefully you) understand that they are functions that can mutate the decorated parts of the class. I would still use them with caution since they might seem magical to other developers (don’t go and decorate everything in your code), but you see how far one decorator (@Injectable
) took us.
References
- viktomas / needle · GitLab - Implementation of the framework from this post.
- tc39/proposal-decorators: Decorators for ES6 classes - The latest stage three proposal.
- The recently added decorator support in
esbuild
Feature request: Decorators support · Issue #104 · evanw/esbuild. - The de-facto reference documentation: JavaScript metaprogramming with the 2022-03 decorators API.
- Overview of TypeScript decorator types (this link is the PR that added ES decorator support to TS).
Acknowledgments
- Thank you, Paul Slaughter, for not settling for an Interface-based DI and nudging me towards learning decorators. Also, thank you, Paul, for suggesting the
WeakMap
improvement.