Tacodiva
Tacodiva

Reputation: 518

TypeScript disallow unsafe casts of types with generic parameters

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

Answers (1)

jcalz
jcalz

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.

Playground link to code

Upvotes: 1

Related Questions