Home > OS >  Wrapping a generic type into an object allows for unsafe assignments
Wrapping a generic type into an object allows for unsafe assignments

Time:08-13

I’m confused by the code below. I have a type such that Type<boolean> is not assignable to Type<true>, but if I wrap it into an object type (BoxType below), then suddenly BoxType<boolean> becomes (wrongly) assignable to BoxType<true>. What is happening and how can I prevent it?

type Type<T> = T extends true ? "true" : "false";

declare const z: Type<boolean>;
const w: Type<true> = z; // Error, expected as z could be "false"

type BoxType<T> = {
    value: Type<T>
}

const x: BoxType<boolean> = {value: z}; // x contains x.value which is either "true" or "false"
const y: BoxType<true> = x; // x.value is "true" ?
const w2: Type<true> = y.value; // Same as w but makes Typescript happy ???

CodePudding user response:

This looks like a limitation of TypeScript.

When checking whether type X is assignable to type Y, the compiler does not always perform a full "structural check", where it fully evaluates both X and Y as much as possible and compares every member of X to that of Y, recursively... since this can be expensive.

Sometimes, if X and Y are both based on the same type... say type X = F<A> and type Y = F<B> for some F<T>, A, and B, then the compiler will check A and B and the variance of F<T> (see Difference between Variance, Covariance, Contravariance and Bivariance in TypeScript). If the compiler marks the definition of F<T> as being covariant in T (sort of a "read" position), contravariant in T (a "write" position), bivariant in T (both read and write), or invariant in T (neither read nor write), then it will use this information to short-circuit a structural check. If F<T> is covariant in T, for example, then if A extends B the compiler knows that F<A> extends F<B> without having to evaluate it fully.


This is all fine, in theory. One problem, though, is that sometimes the compiler will assign a variance marker incorrectly, in order to make things easier and improve performance. And that is what's happening here.

Apparently conditional types of the form type F<T> = T extends U ? X : Y are marked as bivariant in T, meaning that unless it's doing a full structural check, the compiler will happily allow you to assign F<A> to F<B> in cases when A extends B (covariant) and in cases where B extends A (contravariant). This is true even if the conditional type is not really bivariant (and almost nothing really is). See the open issue microsoft/TypeScript#27118 and specificially this comment.

This is what's happening to Type<T>. It has a bivariance marker on it but the type is not bivariant in T:

function foo<T, U extends T>(t: Type<T>, u: Type<U>) {
  u = t; // okay
  t = u; // okay?!
}

If you evaluate concrete types directly like Type<boolean> and Type<true>, they are evaluated fully and the compiler can see that "true" | "false" is not assignable to "true".

But when you nest that inside the BoxType<T> type and the compiler has to compare two BoxType<T>s to each other, it relies on the variance marker and then will happily allow Type<boolean> to be assignable to Type<true> because true is assignable to boolean.

So this is currently just a limitation.


A possible workaround here is to make use of the new optional variance annotations of type parameters introduced in TypeScript 4.7. One of the use cases for these markers are to help the compiler be more accurate in situations where it makes the wrong call.

It looks like Type<T> should really be considered covariant in T and definitely not contravariant. Ideally then you'd like to mark T as an out ("read" position) parameter like this:

type Type<out T> = T extends true ? "true" : "false";
// error! ~~~~~ <-- Variance annotations are only supported in type aliases 
// for object, function, constructor, and mapped types

But I guess that's not currently supported. Well, you can at least to it on BoxType<T>:

type BoxType<out T> = { // okay
  value: Type<T>
}

And now you get the behavior you wanted to see initially.

const x: BoxType<boolean> = { value: z };
const y: BoxType<true> = x; // error

Playground link to code

  • Related