Advanced TypeScript: Type safe dependency injection

Nico Jansen
9 min readJan 25, 2019

--

Type safe dependency Injection

A journey to discover how we can use TypeScript to create a 100% type safe dependency injection framework.

During my time as a professional TypeScript trainer, developers often ask me: “why do we need such an advanced type system?”. They don’t see the need for concepts like literal types, Intersection types, conditional types and rest parameters with tuple types in “real world” applications. It’s a great question and one that is difficult to answer without a good use case.

This set me on the path to find a good use case for them. And boy, did I find one: Dependency Injection, or DI for short.

In this article, I want to take you with me on my journey. I’ll first explain what type safe DI means. Next I’ll show the result, so you have an idea of what we’ll be working towards. After that, we’re off tackling the challenges that come with a statically typed DI framework. I’ll also try to introduce as much relevant emojis as possible 🤔

I’m assuming that you have basic TypeScript knowledge. I hope you find it interesting. Have fun!😆

The goal 🥅

My goal is to create a 100% type safe Dependency Injection (DI) framework in TypeScript. If you’re new to DI, I suggest reading this excellent article by @samueleresca. That article explains what DI is and why you would want to use it. It also introduces InversifyJS, the most popular (standalone) DI framework for TypeScript. It uses TypeScript decorators and reflect-metadata in order to resolve the dependencies at run-time.

InversifyJS does the job… but it is not type safe. Take this code example:

In the above example, you can see that bar is declared as a string, however it's a number at run-time. In fact, it is really easy to make mistakes like this in DI configuration. It's too bad that we're losing type safety for the sake of DI.

My goal is to see if I can teach the compiler how to resolve the dependency graph. If your code compiles, it works! Strings are strings, numbers are numbers, Foo is Foo. No compromises.

The result 🥧

If you’re interested in the result: I succeeded 🎊! You can take a look at typed-inject on github. Here is a simplified code example from the readme:

Classes declare their dependencies in a static inject property. You can use an Injector to instantiate instances of a class with the injectClass method. Any mistakes in the constructor parameters or inject property will result in a compile error, even in larger object graphs.

Intrigued? Damn right you are.

The challenges 🛸

In order to force the compiler into giving compiler errors, we have 3 challenges:

  1. How do we statically declare dependencies?
  2. How do we correlate dependencies to their types in the constructor parameters?
  3. How do we make an Injector that creates instances of types?

Let’s tackle these challenges one at a time.

Challenge 1: 📢 Declaring your dependencies

Let’s start with statically declaring your dependencies. InversifyJS uses decorators. For example: @inject('bar') is used to look for a dependency called 'bar' and inject it. Due to the dynamic way decorators work (it's just a function that executes at run-time), you're not able to verify that the dependency 'bar' exists at compile time.

This means that we can’t use decorators. Let’s think of other ways to declare the dependencies.

Way back when Angular was still called AngularJS, we used to declare our dependencies with a static $inject property on our classes (which we called constructor functions, like good boy scouts 👨‍🌾). The values in the $inject property were called "tokens". It was very important that the exact order of the tokens in the $inject array matched the parameters in the constructor function. Let's try out something similar with MyService:

This is a good start, but we’re not there yet. By initializing our inject property as a string array, the compiler will treat it as a regular string array. There will be no way for the compiler to correlate the 'bar' token to the Bar type.

Introducing: 🕵️‍ Literal types

We want to force a compile error when we do something wrong. In order to know the value of the tokens at compile time, we need to declare it’s string literal as the type:

We’ve told TypeScript that the type of the array is a tuple with value ['httpClient', 'logger']. Now we're getting somewhere. However, we're lazy developers who don't like to repeat our self. Let's make it more DRY.

Introducing: 🛌 Rest parameters with tuple types

We can create a simple helper function that takes in any number of literal strings and returns an exact tuple of literal values. It has a rather inspirational name: rest parameters with tuple types. It looks like this:

As you can see, the theTokens parameter is declared as rest parameters. It captures all arguments in an array. It is typed as Tokens which extends string[]. So any string can be captured. This returns theTokens as a tuple type of literal strings. With this in place, we can "DRY up" our previous example:

As you can see we can just list the tokens once. The type of inject here will be ['httpClient', 'logger']. Much better, don't you think?

Hopefully TypeScript will introduce explicit tuple syntax, so we won’t need the additional tokens helper anymore.

Challenge 2: 🎎 Correlate dependencies

On to the interesting part: making sure that the constructor parameters of an injectable match it’s declared tokens.

Let’s start by declaring the static interface of our MyService class (or any injectable class):

The Injectable interface represents a class that has a constructor (with any number of parameters) and a static inject array that contains the injection tokens of type string[]. It's a start, but not really that useful. It's impossible to force that the values of the tokens correlate to the types of the parameters in the constructor.

Introducing: 🗃️ Lookup types

So we somehow need to teach the TypeScript compiler which tokens belong to which types. Luckily TypeScript has something called lookup types. It is a simple interface that isn't necessarily used as a type directly, instead we're using it as a dictionary (or lookup) for types. Let's declare the values that we can inject in a lookup type Context:

Whenever you want to declare an instance of Logger, you can use the Context lookup type, for example let log: Context['logger']. With this interface in place, we can now specify that the inject property of our MyService class must be a key of Context:

That’s more like it. We narrowed the valid values for inject to a keyof Context array. So only the tokens 'logger' or 'httpClient' can be used. Each parameter in the constructor is of type Context[keyof Context], so they should be either Logger or HttpClient.

But, we’re not there yet. We still need to correlate exact values. This is where generic types come in.

Introducing: 🛠️ Generic types

Let’s introduce some generic typing magic:

Now we’re getting somewhere! We’ve declared a generic type variable Token, which should be a key in our Context. We've also correlated the exact type in the constructor using Context[Token]. While we were at it, we've also added a type parameter R which represents the instance type of the Injectable (for example, an instance of MyService).

There is still a problem here. If we also want to support classes with more parameters in their constructor, we would need to declare a type for each number of parameters:

This is not sustainable. Ideally we want to declare one type for however many parameters a constructor has.

We already know how to do that! Just use rest parameters with tuple types.

Let’s take a closer look at Tokens first. By declaring Tokens as a keyof Context array, we're able to statically type the inject property as a tuple type. The TypeScript compiler will keep track of each individual token. For example, with inject = tokens('httpClient', 'logger'), the Tokens type would be inferred as ['httpClient', 'logger'].

The rest parameters of the constructor are typed using the CorrespondingTypes<Tokens> mapped type. We'll take a look at that next.

Introducing: 🔀 Conditional mapped tuple types

The CorrespondingTypes is implemented as a conditional mapped type. It looks like this:

That’s a mouthful, let’s dive in.

First thing to know is that CorrespondingTypes is a mapped type. It represents a new type that has the same property names as another type, but they are of different types. In this case we're mapping the properties of type Tokens. Tokens is our generic tuple type (extends (keyof Context)[]). But what are the property names of a tuple type? Well, you can think of it as its index. So for tokens ['foo', 'bar'] the properties will be 0 and 1. Support for tuple types with mapped type syntax is actually introduced pretty recently in a separate PR. A great feature.

Now, let’s look at the corresponding value. We’re using a condition for that: Tokens[I] extends keyof Context? Context[Tokens[I]] : never. So if the token is a key of Context, it will be the type that corresponds to that key. If not, it will be of type never. This means that we're signaling TypeScript that that should not occur.

Challenge 3: 💉 The Injector

Now that we have our Injectable interface, it's time to start using it. Let's create our main class: the Injector.

The Injector class has an injectClass method. You provide it with an Injectable class and it will create the needed instance. The implementation is out of scope for this blog article, but you can imagine that we iterate through the inject tokens here and search for values to inject.

A dynamic context

Up to this point, we’ve statically declared our Context interface. It is a lookup type that statically declares which token correlates to which type. It would be a shame if you needed to do that in your application. It would mean that you're entire DI context needs to be instantiated at once and would no longer be configurable. This is not useful.

In order to make the Context dynamic, we provide it as another generic type. I promise you it is the last one 😏. Our new types look like this:

Ok, this should all still look pretty familiar. We’ve introduced TContext which represents our lookup interface for the DI context.

Now for the final piece of the puzzle. We want a way to configure our Injector by dynamically adding providers to it. Let's zoom in on that part of the example code:

As you can see, the Injector has provideXXX methods. Each provide method adds a key to the generic TContext type. We need yet another TypeScript feature to make that possible.

Introducing: 🚦 intersection types

In TypeScript, it’s easy to combine 2 types with &. So Foo & Bar is a type that has both the properties of Foo and Bar. It's called, an intersection type. It's a bit like C++'s multiple inheritance or traits in Scala. We intersect our TContext with a mapped type using string literal tokens:

As you can see, the provideValue has 2 generic type arguments. One for the token literal type (Token) and one for the type of value it wants to provide (R). The method returns an Injector of which the context is { [K in Token]: R } & TContext. In other words, it can inject anything the current injector can inject, as well as the newly provided token.

You might be wondering why the new TContext is intersected with { [k in Token]: R } instead of simply { [Token]: R }. This is because Token by itself can represent a union of string literal types. For example 'foo'| 'bar'. Although this is possible from TypeScript's point of view, explicitly providing a union type when calling provideValue<'foo' | 'bar', _>('foo', 42) would break type safety. It would register both 'foo' and 'bar' as tokens for a number at compile time, but only register 'foo' at run-time. Don't do that in real life. Since you would have to go out of your way to do this, I don't personally think it is a big deal.

Other provideXXX methods work the same way. They return a new injector that can provide the new token, as well as all the old tokens.

Conclusion

The TypeScript type system is really powerful. In this article we’ve combined

🕵️‍ Literal types
🛏️ Rest parameters with tuple types
🗃️ Lookup types
🛠️ Generic types
🔀 Conditional mapped tuple types
🚦 Intersection types

to create a type safe dependency injection framework.

Admittedly, you won’t run into these features all the time. But it’s worth keeping an eye out for the opportunities where they can improve your life.

Last but not least, if you ever need a DI framework in your TypeScript application, why not give typed-inject a chance? 💖

Commendation 🤝

I want to thank the entire TypeScript team for all their hard work these past years. This framework wouldn’t be possible if even one of these features was missing. They’re doing an awesome job! Please keep it up!

If you kept reading this far, well done to you sir! Please leave a comment, I’d love to hear from you.

--

--

Nico Jansen

All-round developer. Core maintainer on Stryker Mutator. I love free speech 💬, but I’ll take free beer as well 🍺.