1

The following code gives the expected error Type 'Type[T][1]' does not satisfy the constraint 'Type[T][0]'.

declare type Type = {
  0: [B, A],
  1: [0, 0],
}

type A = { A: number }
type B = { B: number }

type FirstExtendsSecond<First extends Second, Second> = void;
type Test<T extends 0 | 1> = FirstExtendsSecond<Type[T][1], Type[T][0]>;

However, when we replace [0, 0] on line three with [A, A], the error disappears. This is very unexpected. Is the compiler unable to see that this is not allowed?

jonrsharpe
  • 115,751
  • 26
  • 228
  • 437
Carucel
  • 435
  • 5
  • 11
  • Why is it unexpected? See [this playground](https://tsplay.dev/weQ7gw). You are merely checking if the first is assignable to the second, and also, when being evaluated, `Type[T]` is really just `Type[0 | 1]`. – kelsny Nov 05 '22 at 23:37
  • It's unexpected because `T` is *some subtype* of `0 | 1`, and if it turns out to be `1`, then the constraint is violated. Perhaps [this example](https://tsplay.dev/WY3EzN) is more explicit about what's happening; it is possible to end up evaluating `FirstExtendsSecond` when `X extends Y` isn't true. – jcalz Nov 05 '22 at 23:43
  • Presumably the reason this happens is because the compiler takes a shortcut when checking the definition of `Test` by just substituting the generics with their constraints, and since it's fine when `T` is `0 | 1`, it allows it. And the reason it takes that shortcut is because if it did otherwise, TypeScript would become unusable due to many obnoxious false positives or abysmal compiler performance. I'm hunting through GitHub to see if I can find an authoritative source for this. For now it's just an educated guess. – jcalz Nov 05 '22 at 23:46
  • 1
    Looks like [this comment in GitHub](https://github.com/microsoft/TypeScript/issues/46076#issuecomment-1090750451) is the closest I can get to an authoritative source. OP, does that fully address the question? If so I will write up an answer explaining; if not, what am I missing? (Please mention @jcalz in your reply, so I am notified) – jcalz Nov 05 '22 at 23:59
  • @jcalz It is very disappointing to me that appearantly the Typescript type checker is knowingly unsound. I'm used to strongly typed programming languages, where the compiler prefers to give too many unnecessary compiler errors over accidentally missing out on one (there does not seem to be a superstrict compiler-option in Typescript). So "ensures that the types of the program are correct (typechecked)" in the Handbook [here](https://www.typescriptlang.org/docs/handbook/intro.html) is knowingly false. Thank you so much, that comment in Github does address this question yes. – Carucel Nov 06 '22 at 07:17
  • 1
    It's too bad that there's not a TypeScript Unsoundness Support Group to help people deal with their grief when they first learn that TypeScript is intentionally unsound. The TS team feels that soundness is desirable but not above all else; see [a note on soundness](//www.typescriptlang.org/docs/handbook/type-compatibility.html#a-note-on-soundness) and [TS Design Non-Goal #3](https://github.com/Microsoft/TypeScript/wiki/TypeScript-Design-Goals#non-goals) and [this Q/A](https://stackoverflow.com/a/60922930/2887218) and [this Q/A](//stackoverflow.com/a/70299786/2887218) for more information. – jcalz Nov 06 '22 at 18:59
  • So I'd say that, if you ask the TS team, they'd say that a main goal of the language is indeed to catch type-related bugs, so the statement in the handbook isn't *knowingly false*, except that maybe the word "ensure" needs an asterisk on it. But that's certainly debatable. Anyway I will write up an answer to this question as asked when I get a chance, but I won't go much into the TS soundness-vs-usability arguments; that topic is mentioned quite a lot elsewhere and is a digression from the question in the post. – jcalz Nov 06 '22 at 19:03

1 Answers1

1

Unfortunately, TypeScript is indeed unable to see that what you are doing should be prohibited. The type checker takes shortcuts to check constraints, as described in GitHub issues such as microsoft/TypeScript#46076, and sometimes these shortcuts fail to catch errors.

This is one of the ways in which TypeScript's type system is intentionally unsound in order to keep TypeScript usable. The alternative would either be to: trade false negatives for false positives, breaking lots of real-world code; or try to make the compiler checks more accurate by doing more work, which would almost certainly degrade performance to an unacceptable level.

(For more information about TypeScript's complicated relationship with soundness, see microsoft/TypeScript#9825, as well as Why are TypeScript arrays covariant? and TypeScript type narrowing seems to make incorrect assumption where I've gone into this.)


Here's a slightly more explicit version of your code that explains why there should be an error somewhere:

type FirstExtendsSecond<X extends Y, Y> =
    [X] extends [Y] ? true : false; // should always be true

Since FirstExtendsSecond<X, Y> has a constraint on X, it should be impossible to write FirstExtendsSecond<X, Y> unless X extends Y is true. And it evaluates to a (non-distributive) conditional type where X extends Y is checked, so it should always return true, no matter what, right?

Nope! As you showed, we can do this:

declare type Type = {
    0: [string, number],
    1: [number, string],
}        
type Test<T extends 0 | 1> =
    FirstExtendsSecond<Type[T][1], Type[T][0]>; // no error

type Test0 = Test<0> // false 
type Test1 = Test<1> // false 

There's no error in the definition of Test, but both Test<0> and Test<1> evaluate to false! After all, neither string extends number nor number extends string are true. So why didn't Test's definition fail?


The answer is described in this comment in microsoft/TypeScript#46076. In order to evaluate whether types are compatible with other types, the following intentionally unsound rule is employed:

A type S is related to a type T[K] if S is related to C, where C is the base constraint of T[K]

In the above example that translates to

The type Type[T][1] is related to the type Type[T][0], since Type[T][1] is related to the base constraint of Type[T][0], which is Type[0 | 1][0], which is string | number.

Or, in other words, there is no error because when T is substituted with its constraint 0 | 1, it type checks:

type Test01 = Test<0 | 1> // true

The rule is unsound for the reason we showed. But it's useful. According to a comment on the related issue microsoft/TypeScript#27470:

We do this for reasons of backwards compatibility and also to avoid drowning in generic types.

There have been some soundness improvements over the history of the language, so I suppose there is a possibility that if someone filed an issue about this example, it could be fixed. But my strong suspicion is that such a report would be closed as a design limitation, since it's hard to make such changes without breaking real-world code.

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360