During my time as a professional TypeScript trainer, developers often ask me: "why would you need such an advanced type system?". They usually don't see direct need for concepts like literal types, Intersection types, conditional types and rest parameters with tuple types in "real world" applications.
This set me on the path to find a good use case for them. And boy, did I find one: type safe 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'm assuming that you have basic TypeScript knowledge. I hope you find it interesting.
My goal is to see if I can 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. The 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 runtime.
InversifyJS does the job... but it is not type safe. Take this code example:
https://gist.github.com/03914c0888d0667b48781e111e4faf9e
In the above example, you can see that bar
is declared as a string
, however it's a number at runtime. 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.
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:
https://gist.github.com/d629ca403dd6f38f1534b50a7d30eb6c
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.
In order to force the compiler into giving compiler errors, we have 3 challenges:
- How can we statically declare dependencies?
- How can we correlate dependencies to their types in the constructor parameters?
- How can we make an
Injector
that creates instances of types?
Let's tackle these challenges one by one.
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 runtime), 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
:
https://gist.github.com/a6d24ea10b4019f326098accd7c7591b
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.
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:
https://gist.github.com/36cd4ad767922fa728a34bb11031d41a
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 ourself. Let's it more DRY.
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:
https://gist.github.com/019d1bef0e1f936d2b01825171a8837b
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. The returns theTokens
as a literal tuple type. With this in place, we can "DRY up" our previous example:
https://gist.github.com/e97c72db71d8377ab95f93ad551be383
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 even need the additional tokens
helper anymore.
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):
https://gist.github.com/385280ef87acf9f9f7d5d6f553b391eb
The Injectable
interface represents classes that have a constructor (with any number of parameters) and a static inject
array of that contain the inject 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 constructor parameter types.
We need to narrow which tokens that are valid. We also need to correlate the tokens with the constructor parameters. Luckily TypeScript has something called lookup types. It is a simple interface
that isn't 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
:
https://gist.github.com/a2f40599dda2c5752f877b6f1d6e7bee
With this interface, we can now specify that the inject
property of our MyService
class must be a key of Context
:
https://gist.github.com/7bbdd9d307ace651964ac37eb228c93e
That's more like it. We not only narrowed the valid tokens ('logger'
or 'httpClient'
), we also narrowed the types of the constructor parameters: 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.
Let's introduce some generic typing magic:
https://gist.github.com/60087fb58eff449d67303eadc9b69d95
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:
https://gist.github.com/10f2584891d130294a3ff66d0647a710
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.
https://gist.github.com/b0d31a98c68f6bf1ac57bb95e41864e1
Let's take a closer look. 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.
The CorrespondingTypes
is implemented as a conditional mapped type. It looks like this:
https://gist.github.com/6be0fe34dfc3fba38575db69127731f6
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 it's 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.
Now that we have our Injectable
interface, it's time to start using it. Let's create our main class: the Injector
.
https://gist.github.com/586b057ac2789c07d7c79ae6a3936e3e
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.
Up to this point, we've statically declared our Context
interface. It is a lookup type that statically declared which token correlate 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:
https://gist.github.com/679f940d50fbc172e531dbd32f63f7f7
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:
https://gist.github.com/e984283b37a67dfb2c7f0e331b426792
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.
In TypeScript, it's really 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 out TContext
with a mapped type using string literal tokens:
https://gist.github.com/7ab668713cec1102ea463f500d09270b
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', _>(...)
would break type safety. It would register multiple tokens for a value at compile time, but only register one token at runtime. 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.
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
With all this, it is possible 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? π
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 the features explained in this article 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.