Type compatibility pitfalls in TypeScript
Published on Monday, February 26, 2018 6:00:00 AM UTC in Programming
When I extended good-injector with the possibility to use factory functions, I ran into a strange issue. In one of the unit tests I mistyped registerFactory
for registerInstance
[1][1]
Copy and paste, the root of all evil., but the compiler did not complain at all. It happily accepted a function of type () => T
in a place that required an object of type T
(T
being a generic type argument), and left me a few minutes trying to figure out why my code is not working. I analyzed the problem and realized that it's a side effect of how TypeScript determines type compatibility. Let's see what that means and how to avoid it.
What happened
Here are the signatures of the two functions in question (simplified a bit to demonstrate the situation):
public registerFactory<T>(when: Constructor<T>, then: () => T) {
// ...
}
public registerInstance<T>(when: Constructor<T>, then: T): void {
// ...
}
And here is the respective part of the unit test that did not result in an error:
let factory = () => new Child();
container.registerInstance(Child, factory); // <-- should read "registerFactory", but no error!
This seems very counter-intuitive, and even when I realized my mistake, I could not explain what happens right away.
Generics, TypeScript and type inference
There are some pitfalls with generics in TypeScript, especially if you're used to generics in other languages like C#. And since all my code makes heavy use of them, I first suspected a quirk of the involved code. In the "Do's and don'ts" section of the TypeScript documentation for example, I found this innocent looking single line of advice that at first seemed somehow related:
Don’t ever have a generic type which doesn’t use its type parameter.
The explanation for this is available in a different section, the FAQ of the same documentation:
TypeScript uses a structural type system. This structuralness also applies during generic type inference. When inferring the type [...], we try to find members [...] to figure out what T should be. Because there are no members which use T, there is nothing to infer from, so we return {}.
The most important thing to note here, and the one that pushed me into the right direction, is that TypeScript's type system is a structural one. In contrast to languages like C#, where two different types are not considered the same even when they have the exact same structure, TypeScript will happily accept them interchangeably. This may lead to undesired type compatibility, in particular with some forms of generics that you as a developer intuitively would not consider compatible. The FAQ gives a very interesting example for this:
interface Something<T> {
name: string;
}
let x: Something<number>;
let y: Something<string>;
x = y;
The compiler does not complain about this assignment because it only considers structural compatibility. If you were to have a property of type T
on your interface Something<T>
, the compiler could immediately infer that in one case that property is of type number
and in the other of type string
, and the assignment would fail. That is the reason why generic types should always make use of their type arguments.
Back to the problem
In my case, I do only use generics to constrain arguments that can be passed into my functions, for example to make sure you only use constructors that are able to produce the registered abstract type. For registerInstance
however, the generic type argument is only passed through to the underlying registration, meaning the compiler has to resort to structural compatibility checks as the sole way of infering if the passed in arguments are valid. Now, let's take a look at my type under test above (Child
) to understand the full problem:
export class Child {
}
Not very imaginative, and in this case this caused all the trouble: using a factory function () => new Child()
in a place that expects Child
is not a problem for the compiler, as the latter has no structural restriction at all. I could've passed almost anything here. You can push the limit even further, for example by adding a property to Child
, like this:
export class Child {
public name: string = "Child";
}
This still will not result in a compiler error, because the passed in factory function has, as every other function, an automatic name
property, too - so both types are still considered compatible, even though you don't even have the same members visible in your code! To intentionally break compatibility, you have to add some property that makes the Child
type truly incompatible with the factory function, like:
export class Child {
public someProperty: string = "Child";
}
// ...
let factory = () => new Child();
// Compiler error: Argument of type '() => Child' is not assignable to parameter of type 'Child'.
container.registerInstance(Child, factory);
Adding a method to the Child
type would've also worked. In real-world applications, the types you register are not empty types, so for my good-injector implementation this shouldn't be a problem at all. Still, I added a custom check to at least throw an error at runtime for this particular misconfiguration:
public registerInstance<T>(when: Constructor<T>, then: T): void {
// ...
// this basically checks for "function" !== "object" e.g. if someone uses trivial types for registration
// and passes in a factory function as "then" instead of a real instance (see explanation in unit tests).
if (typeof(then) !== typeof(when.prototype)) {
throw new Error(`You need to register an instance with the same type as the prototype of the source.`);
}
// ...
}
No matter what, always remember about the structural compatibility nature of TypeScript's type system, which will become particularly problematic with generics. One last time, taken straight from the FAQ:
In general, you should never have a type parameter which is unused. The type will have unexpected compatibility and will also fail to have proper generic type inference in function calls.
Watch out!
Tags: Good-injector · TypeScript