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.