简体   繁体   中英

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 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?

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 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 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 string | number (eg, 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 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 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 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

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM