首页 > 解决方案 > Why can TypeScript's compiler not analyze an array of types?

问题描述

Setup: Given some typescript code like:

type ObjectList = {
  [index: string]: string;
};

function makeList(input: ObjectList | string | number): string[] {
  if (typeof input === "string" || typeof input === "number") {
    return [String(input)];
  }
  const arr = [];
  for (const x in input) {
    arr.push(String(input[x]));
  }
  return arr;
}

// Turns a number into a string array
console.log(makeList(123));

// Turns a string into an array
console.log(makeList("hello"));

// Turns an object dictionary into an array
console.log(makeList({ a: "A", b: "B" }));

Ok, simple enough, and it works fine. Critical to type safety is this line:

if (typeof input === "string" || typeof input === "number") {

It ensures that the for in will only operate on the ObjectList, and the TypeScript compiler does a great job of sniffing that out for us! The downside here is how verbose that line of code is, especially if you were to add more types, alternatively it seems we should be able to do this:

if (['string', 'number'].includes(typeof input)) {

However, this will result in a TypeScript compiler error basically indicating the for loop is not sure that the string and number types cannot reach it.

The right-hand side of a 'for...in' statement must be of type 'any', an object type or a type parameter, but here has type 'string | number | ObjectList

Here's a running example:

https://codesandbox.io/s/typescript-arrayincludestypeof-var-dblkn?file=/src/index.ts

Question: In an effort to understand the compiler better — I'm interested in why the compiler exhibits this behavior. What about the [].includes() is incompatible with the compiler? Is the static analysis too deep? Are there runtime considerations I'm not thinking of? Is there some additional "magic" bound into the typeof operator in TS when used in comparisons?

标签: typescript

解决方案


This is mentioned or alluded to in the comments, but I'd like to expand into an answer for completeness.


When you directly check typeof input === "string", the compiler treats this as a type guard which has implications on the apparent type of input. Whereas before the check, input is known only to be of its declared type (e.g., ObjectList | string | number), after the check it is possible for the compiler to give it a narrower, apparent type in areas of code that can only be reached if the check returns true (e.g., string), and another such apparent type in areas of code that can only be reached if the check returns false (e.g., ObjectList | number). This is called control flow analysis.

Such control flow analysis does not work by simulating the behavior of the code on all possible inputs and giving input the union of all possible values it can take. It also does not work via intuition and intellect; the compiler is unable to simply "understand" that some block of code is reachable if and only if some variables are narrower than their declared types. Instead, there are a bunch of specific heuristics that have been implemented, corresponding to distinct identifiable and common coding patterns known to be used as type guards.

Treating typeof someVariable === "string" as a type guard is one such heuristic, the typeof type guard.

But ["string"].includes(typeof someVariable) is not treated as a type guard by the compiler; nobody has implemented such a thing.


An interesting discussion about a similar question/suggestion can be found at microsoft/TypeScript#36275: Why doesn't TypeScript treat Array.includes() or Array.indexOf() as a type guard?

As @RyanCavanaugh (development lead for the TypeScript team at Microsoft) said in a comment:

[The lack of type guard] is the behavior absent the implementation of a feature that would cause it behave the way the OP is proposing. None of the existing narrowing mechanics apply here; we're talking about probably a thousand lines of new code to correctly detect and narrow arrays based on these methods, along with a performance penalty paid by every program because the control flow graph would have to be more granular to set up the possible narrowings caused by any method call, along with a bug trail and confusion trail caused by people expecting non-method versions of these functions to also behave the same way.

I imagine the same would be said for this case; implementing this type guard in the compiler would take a lot of work, make the compiler slower for everyone, and introduce more complexity and possible bugs, all for the benefit of supporting a relatively uncommon use case.


Luckily, though, TypeScript does give developers the ability to write their own custom type guards via user-defined type guard functions. You need to refactor your intended type guard into a boolean-returning function that acts upon one of its parameters, which means that you still won't get ["string"].includes(typeof input) to work out-of-the-box, but you can at least get closer.

Here's one possible implementation:

interface TypeofMap {
  string: string;
  number: number;
  bigint: bigint;
  boolean: boolean;
  symbol: symbol;
  undefined: undefined;
  object: object | null;
  function: Function
}
function typeofIncludes<K extends keyof TypeofMap>(typeofs: K[], val: any): val is TypeofMap[K];
function typeofIncludes(typeofs: Array<keyof TypeofMap>, val: any) {
  return typeofs.includes(typeof val);
}

I've defined a TypeofMap interface whose only purpose is to represent the mapping between the string values returned by the JS typeof operator and the TypeScript types they represent. It's not perfect; there is no such TypeScript type as a "non-function object" corresponding to the "object" string, and rather than trying to simulate one I just use object.

Then the typeofIncludes() function takes an array typeofs of typeof-outputtable strings and a value val, and returns a boolean value which is true if val is one of the relevant types.

You can test it out here:

function makeList(input: ObjectList | string | number): string[] {
  if (typeofIncludes(["string", "number"], input)) {
    return [String(input)];
  }
  const arr = [];
  for (const x in input) {
    arr.push(String(input[x]));
  }
  return arr;
}

This works. Inside the "then" clause of if (typeofIncludes(["string", "number"], input)), input has been narrowed to string | number, while in the "else" clause it has been narrowed to ObjectList.


Playground link to code


推荐阅读