TypeScript: Types `number` and explicitly constrained `T extends unknown` shouldn't be comparable
const num = 1;
function check<T extends unknown>(x: T) {
return x === num;
}
check(num);
Expected: Error: This condition will always return ‘false’ since the types ‘T’ and ‘1’ have no overlap. Actual: No error.
Contrast this with the following example from #32768.
const num = 1;
function check<T>(x: T) {
return x === num;
}
check(num);
About this issue
- Original URL
- State: open
- Created 5 years ago
- Reactions: 1
- Comments: 24 (10 by maintainers)
This seems backwards to me; it’s clear that “This condition will always return ‘false’” is a lie. In the example, it will return
true
!The current
<T extends unknown>
behaviour is the correct one. The<T>
behaviour is wrong.I think this will be my last comment on this issue, because it doesn’t affect me too strongly (I can always use
as any
to get around any errors introduced), but to give a more concrete (but still contrived) example, since I think a lot of people are getting hung-up on the simplicity ofisSame
:An additional desirable symmetry is that a generic function should be type checked such that an error is issued if any concrete version of the function would have errored. In this case, we can easily construct an erroring instatiation
isSame<string, number>
:Moreover, you have many options when writing
isSame
to decide how you want both the caller and the implementation to be checked. If you want to take any two values and compare them freely in the body,unknown, unknown
is the proper signature. Choosing to write this function with type parameters should imply some other behavior because providing different behavior is why different syntax exists.Useful, consistent, correct: Pick any two 😅
@davidje13 The problem with that approach is that any two unrelated type parameters become comparable, which is a bug in many cases.
I’ll probably wait on the design meeting before looking more into this — it’s not quite clear what the new behaviour should be. The code-path I linked on the other issue is the one influencing the unconstrained case, which means that the implicit constraint of
T
in<T>
is the empty object type.<T extends unknown>
should be{}
to bring them into parity? That seems wrong.unknown
constraint in<T extends unknown>
and the implicit{}
constraint in<T>
? This would be a breaking change in both directions (but maybe reasonable?).<T extends unknown>
where we ignore the constraint completely, but then<T extends unknown>
would be different to<T>
, with the former being more conservative.There may be more options I don’t know about.
Everyone’s raised some good points and I really appreciate the discussion here. 👍
Ah right, it’s an optimization in a larger generic function. Yeah, optimizations like that are generally one case where you have to go over the type system’s head at some point—speaking from experience.
Probably a case for
// @ts-ignore
(which I would prefer over anany
cast here).@davidje13
The problem here is an issue of “caller” vs “implementer” and what you know vs what you don’t know.
To the caller,
<string, number>
is a perfectly valid instantiation because the function implementation is treated as a black box. It doesn’t know the the implementation is trying to compare the types.If it knew, it would give you errors, too.
To the implementer,
<string, number>
is an invalid instantiation because if it is always instantiated with<string, number>
, then the types are not comparable because there is no overlap.It doesn’t know that
<string, number>
will never be instantiated. It just knows that it is possible, and that if it is instantiated, there’s something wrong.That
isSame
would have been better as,This way, it doesn’t matter what
T
andU
are, there will always be overlap (becauseU
is a subtype ofT
)@fatcerberus no that’s not what I’m saying (I updated my comment to add “at runtime” just as you posted that to clarify that exact point)
My point is that type-wise, the check is fine, but in that case the error is 100% correct about the reachability. It can be proven at compile time that the check will, guaranteed, be
false
every time it runs. That makes the check pointless, and TypeScript is correct to produce an error.My comment about
1 === 'hello'
being ok at runtime was just to point out that the operator itself has no qualms about the types of its inputs (i.e. it won’t throw if given incomparable types).The discussion here is about what to do when it cannot be determined at compile time whether it will always be true, or always be false
From a purely theoretical standpoint:
foo
is universally quantified overT
. i.e., for all typesT
, there is a functionfoo
which accepts a value of typeT
and returns aboolean
.However, inside the function,
T
is existentially quantified: the implementation can be written assuming it represents a single concrete–but unknown–type. Therefore it’s tempting to also assume the comparibility relation (the question of whetherx === 1
is kosher) should satisfy the logical statement:But this is not necessarily sound. Because the function as a whole is universally quantified,
x === 1
can’t be assumed to be sound until you’ve satisfied the stronger constraint of:Which seems to be what @RyanCavanaugh is getting at above (minus the nerdiness 🤓).
@jack-williams see the original issue #32768 for my actual use-case which kicked this off.
There’s clearly some miscommunication here because both of us think our view is the only possible logical interpretation. So to maybe clarify my perspective:
What we know before the comparison:
typeof t
⊆T
⊆unknown
typeof u
⊆U
⊆unknown
What we additionally know if the comparison is true:
typeof t
⊆typeof u
typeof u
⊆typeof t
typeof t
=typeof u
What we additionally know if the comparison is false:
Clearly the
false
case is possible, and I don’t think that’s disputed.The
true
case has the following combined type assertions:typeof t
⊆T
⊆unknown
typeof u
⊆U
⊆unknown
typeof t
=typeof u
Which resolves to saying that ∃ S : S ⊆ (
T
∩U
), where S ≠ ∅ (above, S would betypeof t
ortypeof u
). Or more simply, (T
∩U
) ≠ ∅At this point, we do not have enough information to formally decide this one way or the other; (
T
∩U
) might be the empty set, or it might not. Therefore, this branch might be possible, or might not. The undecidability means that we cannot possibly claim it “will always return ‘false’”, though that in itself could be fixed by just changing the error wording.What I mean by “breaking the correctness of the type checking” is exactly this; if we choose to assume undecidable states cannot happen, we are no-longer modelling how the program can actually operate (i.e. the real behaviour it may exhibit). The only way to continue modelling how the program can actually operate is by assuming that undecidable states can happen, until we have enough information to prove that they cannot happen (at which point they cease being undecidable).
To hammer the point home, consider the closely related code:
Once again,
typeof t
andtypeof u
might have no overlap (i.e. one may be a string and the other a number), but clearly we want to allow the comparison (and currently do allow it). The only difference here is the level of indirection; in this example the uncertainty is with (typeof t
∩typeof u
) ≟ ∅, whereas before it was a level higher. Going a level deeper again, we start looking at literal types and find ourselves defining the===
operator itself as (typeof t
∩typeof u
) ≠ ∅ (i.e. it is defined as a runtime test for the region of undecidability). Doing differently at higher levels of indirection introduces an asymmetry in the type system.