Reputation: 518
I've been learning TypeScript and I've encountered something strange. Take a look at this code:
interface Foo {
sayHello(): void;
}
class Bar implements Foo {
sayHello(): void {
console.log("Bar Hello!");
}
sayGoodbye(): void {
console.log("Bar Goodbye!");
}
}
class Baz implements Foo {
sayHello(): void {
console.log("Baz Hello!");
}
}
class TContainer<T extends Foo> {
public value : T;
constructor(value : T) {
this.value = value;
}
}
function run() {
// barContainer.value should only ever be of type 'Bar'
const barContainer : TContainer<Bar> = new TContainer(new Bar());
// Cast barContainer and allow it to hold anything that implements Foo
// (!!!) This should not be allowed
const fooContainer : TContainer<Foo> = barContainer;
// Set fooContainer.value (and thus barContainer.value) to a type without sayGoodbye
fooContainer.value = new Baz();
// Note that just writing barContainer.value = new Baz(); does correctly cause a compile time error.
// Compiler still thinks barContainer.value is of type Bar, so this will crash at runtime
barContainer.value.sayGoodbye();
}
TypeScript seems to allow casting generic types in an unsafe way which causes breakages at runtime. Other language's like C# or Java do not allow generic types to be converted like this, because it's unsafe.
Is there any way to get the TypeScript compiler to report this as an error? If not, why not and how can I make types like TContainer
safe?
Upvotes: 1
Views: 197
Reputation: 329658
This is a general limitation of TypeScript; object types are compared covariantly in the types of their properties, meaning that if Y extends X
then {a: Y} extends {a: X}
. (See Difference between Variance, Covariance, Contravariance and Bivariance in TypeScript for an in-depth discussion of variance.)
For read-only properties that is safe, but in the face of property assignment, it's definitely unsound, as you've shown. But TypeScript is not intended to be fully sound, and convenience often trumps soundness. See Why are TypeScript arrays covariant? for a more in-depth discussion.
So TContainer<T>
is covariant in T
because the only dependence on T
is that there is a value
property of type T
. One workaround would therefore be to add a contravariant dependency, so that TContainer<T>
is considered to be invariant in T
. As long as you have the --strictFunctionTypes
compiler flag enabled, then if TContainer<T>
has a function-valued property that accepts an argument of type T
, then it will have this effect:
class TContainer<T extends Foo> {
public value: T;
private invariant = (x: T) => x; // add this or something like it
constructor(value: T) {
this.value = value;
}
}
Now you get the desired error:
const barContainer: TContainer<Bar> = new TContainer(new Bar());
const fooContainer: TContainer<Foo> = barContainer; // error!
Note that it will not work if you make invariant
a method instead of a function-valued property:
class TContainer<T extends Foo> {
public value: T;
private bivariant(x: T) { return x }; // <-- nope
constructor(value: T) {
this.value = value;
}
}
const barContainer: TContainer<Bar> = new TContainer(new Bar());
const fooContainer: TContainer<Foo> = barContainer; // no error
since method parameters are compared bivariantly... this is, again, unsound, but convenient.
Upvotes: 1