Skip to content

Instantly share code, notes, and snippets.

@nicojs

nicojs/blog.md Secret

Created January 15, 2019 14:45
Show Gist options
  • Save nicojs/a801982e49f8cfe3b175c0982f2e8dee to your computer and use it in GitHub Desktop.
Save nicojs/a801982e49f8cfe3b175c0982f2e8dee to your computer and use it in GitHub Desktop.

Advanced TypeScript: Type-safe dependency injection

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.

The goal πŸ₯…

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.

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:

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.

The challenge πŸ›Έ

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

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

Let's tackle these challenges one by one.

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 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.

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:

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.

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:

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.

Challenge 2: Correlate dependencies and constructor parameters

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.

Introducing: πŸ“– Lookup 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.

Introducing: πŸ› οΈ Generic types

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.

Introducing: πŸ”€ Conditional mapped tuple types

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.

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.

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.

A dynamic context

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.

Introducing: 🚦 intersection types

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.

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

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? πŸ’–

Final remarks

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment