Reputation: 51
Code
type A = {
name: string
}
type B = {
id: number
}
function foo<T extends A | B>(target: T[]): T[] {
const res = [];
// const res: T[] = []; // Adding type annotation to res can resolve the error but why?
for (const e of target) {
res.push(e);
}
return res // TS think res is a type of (A|B)[] --> error!
}
function aoo<T extends A>(target: T[]): T[] {
const res = [];
for (const e of target) {
res.push(e);
}
return res // TS think res is a type of T[] --> no error!
}
Basically, I have two types A and B, and two generic functions foo
and aoo
.
The first function foo
has a generic type T
which is constrained by a union: A | B
, while the latter one is only constrained by A
.
Error
The error appears in foo
, and the reason is that TS thinks the result is a type of (A|B)[]
which is incompatible with T[]
. However, the return type of aoo
is inferred as T[]
as I expected. This is weird to me, I don't understand why TSC doesn't infer the return type of foo as T[
], and what's the difference between these two cases?
Upvotes: 5
Views: 2905
Reputation: 330436
This is a side effect of the support added in Typescript 4.3 to contextually narrow values of generic types, as implemented by microsoft/TypeScript#43183. There was a longstanding open issue at microsoft/TypeScript#13995 where control flow analysis would not work to narrow values of generic types constrained to union types, the same way it would work with values of specific types.
For example, the following always worked, where x
is of the specific union type string | number
:
function checkSpecific(x: string | number) {
if (typeof x !== "string") {
console.log(x.toFixed(2)); // okay
}
}
Here the fact that typeof x !== "string"
allows the compiler to narrow x
to number
and see that it has a toFixed()
method. But the following did not work before TypeScript 4.3, where x
is of the type T extends string | number
:
function checkGeneric<T extends string | number>(x: T) {
if (typeof x !== "string") {
console.log(x.toFixed(2)); // error (before TS4.3)!
// ---------> ~~~~~~~
// Property 'toFixed' does not exist on type 'T'.
}
}
The compiler stubbornly refused to see that typeof x !== "string"
had any implications on the type of x
. One thing the compiler can't do is assume that the type parameter T
itself should be narrowed. After all, maybe T
really is the full union string | number
(e.g., checkGeneric(Math.random()<0.5 ? "abc" : 123)
) and so it wouldn't be right to narrow T
. But people who wrote the above code don't care about narrowing T
, they wanted x
to be narrowed from T
to number
.
And so with TypeScript 4.3, in certain situations when given values of generic types where the generic type parameter is constrained to a union, these values will first be widened all the way to the constraint and then narrowing can happen:
function checkGeneric<T extends string | number>(x: T) {
if (typeof x !== "string") {
console.log(x.toFixed(2)); // okay (TS4.3 and above)
}
}
The compiler has decided to take x
and see its type not as the generic type T
, but as the specific type string | number
to which T
is constrained. And once it does this, then typeof x !== "string"
can narrow x
to number
as desired.
Unfortunately, in your code, the same analysis leads to surprising behavior. Prior to TypeScript 4.3, there would be no error:
function foo<T extends A | B>(target: T[]): T[] {
const res = []; // <-- auto typed
for (const e of target) {
res.push(e); // <-- res is inferred as T[] before TS4.3
}
return res // okay
}
The variable res
is considered to be an "auto-typed" or "implicit any
" variable because the compiler cannot use its initializer to infer the type; it needs to wait to see what you do with it and then evolve the type based on that. For arrays like res
this was implemented in microsoft/TypeScript#11432.
Before TypeScript 4.3, when you called res.push(e)
, the compiler would see that e
is of type T
, and thus res
is now evolved to be of type T[]
, and then return res
is fine.
But starting with TypeScript 4.3, this has changed:
function foo<T extends A | B>(target: T[]): T[] {
const res = []; // <-- still auto typed
for (const e of target) {
res.push(e); // <-- res is inferred as (A | B)[] starting with TS4.3
}
return res // error!
}
The res
variable is still auto-typed and evolves when you call res.push(e)
. But because the value e
is of a generic type constrained to a union, the compiler uses its new behavior to first widen e
from T
all the way to the constraint A | B
. And that means that res
's type is (A | B)[]
and you get an error. Since you never tried to narrow e
from A | B
to either A
or B
in this code, the added support for control flow analysis is completely useless for your purposes.
Oh well.
Note that neither the old nor the new behavior is incorrect; a value of type T
where T extends XXX
can be widened to XXX
safely. It's just that there are some situations in which T
will be more or less useful than XXX
. The heuristics added in TypeScript 4.3 improved things for a lot of situations, but unfortunately made things worse in others. I'd be interested in seeing what would happen if someone filed an issue about this, but I wouldn't go so far as to call it a bug.
Playground link to code pre-ms/TS#43183
Playground link to code post-ms/TS#43183
Upvotes: 2