1

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?

Tacodiva
  • 391
  • 2
  • 17
  • Have a read at this: https://www.typescriptlang.org/docs/handbook/type-compatibility.html – Mario Vernari Jan 10 '23 at 05:25
  • Which is the primary question here? If it's your first question and the "why not", then the answer is "no" and I'd probably close this as a duplicate of [this question about array covariance](https://stackoverflow.com/a/60922930/2887218) which is the same issue: properties are related covariantly even though this is unsound in the face of mutation – jcalz Jan 10 '23 at 17:33
  • If it's your second question then I'd suggest something like the code at [this playground link](https://tsplay.dev/wE4KyW) to make the compiler treat `TContainer` as invariant in `T`. Does this fully address your question? If so I could write up an answer; otherwise what am I missing? – jcalz Jan 10 '23 at 17:36
  • @jcalz Well, I wasn't sure what was my primary question because I wasn't sure if there was an answer to the first one. That does look like a solution to the problem if you where to write up an answer :) I would appreciate some more elaboration on why it works, though – Tacodiva Jan 11 '23 at 01:40

1 Answers1

1

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

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • That makes a lot of sense, it's a shame there is no way to tell the compiler to make the T generic variable invariant without this kinda 'hacky' solution. I probably wont end up using it just because it would probably be pretty confusing to anybody reading the code who isn't 'in the know'. Thanks for furthering my understanding, though – Tacodiva Jan 12 '23 at 00:55