Execute Program

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 .filter method 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]Pass Icon
  • 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 just number.

  • To do that, we can write an isNumber type predicate, then use that function as the callback in .filter. In the next example, note the type predicate maybeNumber is number in 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]Pass Icon
  • This raises a question: why does giving .filter a type predicate change .filter's return type? We didn't tell .filter to 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: .filter is 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 as unknown, but that's not important for our current discussion.

  • This overload works on an existing Array<T>, the callback function takes a T, and then .filter returns a new Array<T>. For example, we might filter an Array<number> with a callback like (n: number) => n < 3, giving us a new Array<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 predicate parameter 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 is Array<S>, where S is the narrowed type from the type predicate. We can use a type predicate to filter an Array<string | number>, giving us back just an Array<number>. In that example, T is string | number and S is just number.

  • To demonstrate that concretely, here's some TypeScript pseudo-code where we've replaced the type parameters with number | string and number. 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 .filter on 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 .filter starts out with filter<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'.Pass Icon
  • Our type predicate tried to narrow the array elements to string. But string extends (number | undefined) is false, so the <S extends T> in the .filter overload is false. TypeScript knows that it can't use the type predicate overload.

  • TypeScript then tries to use the second, more general-purpose .filter overload, effectively ignoring the type predicate. In that overload, the .filter method's return type is unchanged, so it returns a (number | undefined)[], which is not assignable to our string[] array.

  • Now that we've seen the details of how this works, let's return to our original example: we wrote an isNumber type predicate, then used it as the callback in a .filter call. 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]Pass Icon
  • We used maybeNumber is number to mark isNumber as 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]Pass Icon
  • This isn't specific to .filter. TypeScript automatically infers type predicates in many situations. Here's the familiar isNumber function 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 returns typeof 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]Pass Icon
  • 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.