Bo Li
Bo Li

Reputation: 51

How Typescript infer type of an array with generic type

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!
}

TS Playground

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

Answers (1)

jcalz
jcalz

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

Related Questions