Tomas Vik

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:

  1. We should be able to decorate any class without changing it.
  2. The decorator captures the class’ interface and dependencies.
  3. The decorator must be type-safe. If the decorator types don’t match the class types, the compiler must fail.
  4. 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 InterfaceIds:

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 of InterfaceIds.
  • 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:

  1. Decoration
    • We need to tell our DI that a class implements an interface and needs some dependencies
    • @Injectable implementation does this
  2. Initialization
    • Once we know about all classes and dependencies, we need to initialize them in the right order
    • Container.init will do this
  3. 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:

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

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.