Home > Software design >  TypeScript narrowing of array does not work
TypeScript narrowing of array does not work

Time:01-16

In my TypeScript code I work with arrays of strings which can only be certain letters, so for these I use the type ("A" | "B")[] if the letters are A and B (for example). Now, I have a function which has an arbitrary array of string as an input (so the type is string[]), and I need to validate if this is actually an array of the mentioned type, in order to narrow down the type of result. However, this does not work. Here is a minimal example which demonstrates the issue.

function testFunction(strList: string[]): ("A" | "B")[] | undefined {
    if (strList.every((x) => x === "A" || x === "B")) {
        return strList;
    }
    return;
}

This results in a Type error. In line 3, TypeScript complains:

Type 'string[]' is not assignable to type '("B" | "A")[]'.

But haven't I already narrowed it down? How can we fix this?

This issue does not appear when we don't work with arrays. Namely, the following function definition has no TypeError:

function testFunction2(str: string): "A" | "B" | undefined {
    if (str == "A" || str == "B") return str;
    return;
}

CodePudding user response:

Unfortunately, the kind of narrowing that happens to x when you check x === "A" | x === "B" doesn't persist across function boundaries. This general limitation in TypeScript is discussed in depth in microsoft/TypeScript#9998. It would be prohibitively expensive for the compiler to attempt to automatically keep track of the state of variables in the face of function calls and returns; it would have to essentially simulate all possible runnings of your program, since every pass through a function body could result in a different state.

Luckily, there is a way to tell the compiler that a boolean-returning function should act as a type guard on one of its arguments. You can turn it into a custom type guard function by annotating its return type as a type predicate of the form arg is Type:

function testFunction(strList: string[]): ("A" | "B")[] | undefined {
    if (strList.every((x): x is "A" | "B" => x === "A" || x === "B")) {
        return strList; // okay
    }
    return;
}

Here the callback (x) => x === "A" || x === "B" has been annotated with the return type x is "A" | "B". So now Typescript knows that the callback narrows the type of its input.

And yes, currently you need to do such an annotation manually. The compiler is neither able to verify that your function implementation satisfies this type predicate (as requested in microsoft/TypeScript#29980 and closed as too complex), nor can it infer the type predicate from the implementation (as requested in the longstanding open issue microsoft/TypeScript#38390).


Now TypeScript selects the second overload for the every() array method, with the following call signature:

interface Array<T> {
  every<S extends T>(
    predicate: (value: T, index: number, array: T[]) => value is S, 
    thisArg?: any
  ): this is S[];
}

so if the array is of type string[], and the callback is of type (x: string) => x is "A" | "B", then the every() method itself will return a type predicate of type this is ("A" | "B")[]. If it returns true, then the array will be narrowed to ("A" | "B")[]; otherwise it will stay string[].

And so now your function returns ("A" | "B")[] | undefined, as desired.

Playground link to code

  • Related