Thinking About TypeScript, Dependency-Injection Tokens, Type-Checking, And Architecture In Angular 2 RC 4
In an Angular 2 application, type-annotations in a constructor function serve two distinct purposes. On the one hand, they tell TypeScript's type-checker what interface that value exposes. And, on the other hand, they tell the Angular 2 Injector which value to instantiate (if not already cached) and provide to the constructor. Essentially, these type-annotations act as both a Type and a dependency-injection (DI) token. This dual nature ends-up placing an interesting (and beneficial) constraint on the way that you architect your Angular 2 applications.
Imagine that you have a constructor function:
constructor( value: TypeA ) {
this.value = value;
}
The ":TypeA" annotation tells TypeScript's type-checker that "value" will only contain the properties and methods exposed by the interface defined by the TypeA class. It also tells Angular 2's Injector to inject the class instance associated with the dependency-injection token, "TypeA".
From an Angular 2 perspective, however, that injected value could be anything. During our bootstrapping process, or in any provider annotation higher-up in the component tree, we could easily create the following DI-token association:
[
{
provide: TypeA,
useClass: CustomTypeA
}
]
This tells Angular 2 that it should inject an instance of "CustomTypeA" any time a relevant constructor requires a value of "TypeA". Angular doesn't care what interface these values implement. It doesn't care if one is a sub-class of the other. For the Angular 2 injector, this is nothing more than a mechanical substitution.
TypeScript, on the other hand, very much does care. If you require something of type, "TypeA", TypeScript doesn't know that the injector actually injected something of type, "CustomTypeA". And, in fact, if you try to consume a property that is only available on "CustomTypeA", the type-checker will throw an error.
To get around this, you could try and downcast the value to "CustomTypeA" (though I've never actually tried this). From a philosophical standpoint, however, this feels icky. What you're doing is essentially telling TypeScript that you - as the developer - know and accept that the type-annotations make no sense.
You can also try a little slight-of-hand in the provider definitions, aliasing one DI-token for another:
[
{
provide: TypeA,
useClass: CustomTypeA
},
{
provide: CustomTypeA,
useExisting: TypeA
}
]
This seems, at first, like a circular dependency, but it's not. This configuration tells Angular 2 to use the class "CustomTypeA" for the DI-token "TypeA". And, for the DI-token "CustomTypeA", use whatever value is already associated with the DI-token, "TypeA". It's the separation of tokens and classes that makes this valid. You're essentially associating two different DI-tokens with the same injector value.
Then, you could simply change your type-annotation:
constructor( value: CustomTypeA ) {
this.value = value;
}
This tells Angular 2's injector to inject the value associated with the DI-token, "CustomTypeA", which is an alias for the DI-token, "TypeA", which is associated with the class, "CustomTypeA". This works; and, it satisfies both the Injector and the TypeScript type-checker constraints.
At this point, however, you're probably thinking:
Why not just associate the DI-token CustomTypeA with the class CustomTypeA and call it a day?
Excellent question. And, I believe this points to the inherent problem with this approach. When we do this, we're forgetting that what we originally wanted - based on our provider configuration - was a seamless substitution. When we associated the class, "CustomTypeA", with the DI-token, "TypeA", what we were implicitly stating was that this is a polymorphic substitution; that "TypeA", for all intents and purposes, is a super-type for "CustomTypeA". Therefore, if we inject a value that has the type-annotation, "TypeA", we can only assume that the injected value has properties and methods defined by the TypeA class, even if it's actually a "CustomTypeA" instance.
The architectural point that I'm driving at is that while type-annotations serve a dual-purpose - type-checking and dependency-injection - we can't forget just how important the type-checking functionality is. And, if we're jumping through hoops to try and get around type-checking, it's probably an indication, or a code smell, that something else is wrong with our Angular 2 architecture, such as a dependency relationship moving in the wrong direction.
Want to use code from this post? Check out the license.
Reader Comments