Advanced TypeScript: Type Predicate Inference
Welcome to the Type Predicate Inference lesson!
This lesson is shown as static text below. However, it's designed to be used interactively. Click the button below to start!
In most cases, the JavaScript
.filtermethod works as we'd expect in TypeScript. For example, when we filter an array of numbers, we get back another array of numbers.>
const filtered: number[] = [1, 2, 3, 4].filter(x => x < 3);filtered;Result:
[1, 2]
Sometimes, our filter callback function also needs to narrow the type. For example, we might have an array of
number | string, and filter it down to an array of justnumber.To do that, we can write an
isNumbertype predicate, then use that function as the callback in.filter. In the next example, note the type predicatemaybeNumber is numberin place of a normal return type.>
function isNumber(maybeNumber: number | string): maybeNumber is number {return typeof maybeNumber === 'number';}const numbersAndStrings: (number | string)[] = [1, 'a', 2, 'b'];/* This assignment works because `.filter` respects our `isNumber` type* predicate. This `.filter` call narrows the array from (number |* string)[] to just number[]. */const numbers: number[] = numbersAndStrings.filter(isNumber);numbers;Result:
[1, 2]
This raises a question: why does giving
.filtera type predicate change.filter's return type? We didn't tell.filterto use the type predicate. It seems to have used it automatically.This happens thanks to two features that we've already seen: generic constraints and function overloads. Those two features usually show up in library and framework code, and that's true here as well:
.filteris a built-in part of JavaScript.First, we'll show TypeScript's built-in type definition for
.filter, which we found in the file "node_modules/typescript/lib/lib.es5.d.ts". Then we'll see how it works. We've modified the type slightly for consistency, for formatting, and to remove details that aren't important here.>
interface Array<T> {filter<S extends T>(predicate: (value: T) => value is S): Array<S>filter(predicate: (value: T) => unknown): Array<T>}There are two function overloads here. The second is the more familiar version of
.filter, so we'll start with that. In this case, the callback function isn't a type predicate; it's just a normal function that returns a value. Its return type happens to be declared asunknown, but that's not important for our current discussion.This overload works on an existing
Array<T>, the callback function takes aT, and then.filterreturns a newArray<T>. For example, we might filter anArray<number>with a callback like(n: number) => n < 3, giving us a newArray<number>.Now let's consider the first overload above, which is used when we filter with a type predicate function. It's almost identical to the second, but the
predicateparameter is explicitly annotated with a type predicate,value is S. TypeScript uses this overload when we give it a type predicate function as the callback.This time,
.filter's return value isArray<S>, whereSis the narrowed type from the type predicate. We can use a type predicate to filter anArray<string | number>, giving us back just anArray<number>. In that example,Tisstring | numberandSis justnumber.To demonstrate that concretely, here's some TypeScript pseudo-code where we've replaced the type parameters with
number | stringandnumber. We only show the overload that concerns type predicates. This isn't legal TypeScript code, but it gives you a sense of what's happening when we actually call.filteron an array with concrete, non-generic types.>
interface Array<number | string> {filter<number extends number | string>(predicate: (value: number | string) => value is number): Array<number>}Finally, the type predicate overload for
.filterstarts out withfilter<S extends T>(...). That generic constraint stops us from making mistakes in our type predicate. If we try to provide a type predicate that's incompatible with the array's original type, we'll get a type error. Here's an example:>
function isString(maybeString: string | undefined): maybeString is string {return typeof maybeString === 'string';}const numbersAndUndefineds: (number | undefined)[] = [1, undefined, 2];const strings: string[] = numbersAndUndefineds.filter(isString);strings;Result:
type error: Type '(number | undefined)[]' is not assignable to type 'string[]'. Type 'number | undefined' is not assignable to type 'string'. Type 'undefined' is not assignable to type 'string'.Our type predicate tried to narrow the array elements to
string. Butstring extends (number | undefined)is false, so the<S extends T>in the.filteroverload is false. TypeScript knows that it can't use the type predicate overload.TypeScript then tries to use the second, more general-purpose
.filteroverload, effectively ignoring the type predicate. In that overload, the.filtermethod's return type is unchanged, so it returns a(number | undefined)[], which is not assignable to ourstring[]array.Now that we've seen the details of how this works, let's return to our original example: we wrote an
isNumbertype predicate, then used it as the callback in a.filtercall. That narrowed the filtered array's type.>
function isNumber(maybeNumber: number | string): maybeNumber is number {return typeof maybeNumber === 'number';}const numbersAndStrings: (number | string)[] = [1, 'a', 2, 'b'];/* This assignment works because `.filter` respects our `isNumber` type* predicate. This `.filter` call narrows the array from (number |* string)[] to just number[]. */const numbers: number[] = numbersAndStrings.filter(isNumber);numbers;Result:
[1, 2]
We used
maybeNumber is numberto markisNumberas a type predicate, but we didn't have to do that! Even when we write the simple callback version, without the type predicate syntax, TypeScript infers that it's a type predicate. It correctly narrows the array type automatically.>
const numbersAndStrings: (number | string)[] = [1, 'a', 2, 'b'];/* This assignment works because our callback function is inferred as a type* predicate. `.filter` then respects the type predicate, narrowing the type.* This `.filter` call narrows the array from (number | string)[] to just* number[]. */const numbers: number[] = numbersAndStrings.filter(n => typeof n === 'number');numbers;Result:
[1, 2]
This isn't specific to
.filter. TypeScript automatically infers type predicates in many situations. Here's the familiarisNumberfunction again, but this time we don't bother marking it as a type predicate. It still narrows types. TypeScript can see that it's a type predicate because it returnstypeof maybeNumber === 'number'.>
function isNumber(maybeNumber: number | string) {return typeof maybeNumber === 'number';}const numbersAndStrings: (number | string)[] = [1, 'a', 2, 'b'];/* What's the return type of the `.filter` call? Is it (number | string)[], or* is it narrowed to just number[]? */const numbers: number[] = numbersAndStrings.filter(isNumber);numbers;Result:
[1, 2]
If TypeScript can do this automatically, then why bother learning to write type predicates at all? And why did bother to dig into the types for
.filter, to see how the type predicates worked?TypeScript can infer some type predicates, but unfortunately it can't infer every possible predicate. This is similar to how type inference in general works: TypeScript can infer a variable's type in many cases, but there are situations where we're forced to declare the type manually. There are times where we can't rely on inference, so we need to understand the underlying type system features.