typescripttypespredicate

Typescript type predicate mapped through to tuple elements


Consider a union of two types with a type predicate

type Maybe<T> = T | Error
function isValid<T>(value: Maybe<T>): value is T {
  return !(value instanceof Error)
}

Lets have code with several computed Maybe values and we want a type predicate which summarily checks all of them to not be an Error:

const a: Maybe<string> = ... // some function returning Maybe<string>
const b: Maybe<number> = ...
...

if (![a, b, /*and more*/].every(isValid)) {
  return;
}
const realA: string = a;
//Type 'Maybe<string>' is not assignable to type 'string'.
//  Type 'Error' is not assignable to type 'string'.(2322)

playground

Despite the every(isValid) the compiler cannot derive that none of a, b,... is a Maybe any more.

Of course I could write

if (!isValid(a) && !isValid(b) && ...) {
  return;
}

to help the compiler along. But I have 5 to 10 as and bs and am in general curious if there is a solution. If have tried

const data = [a, b].filter(isValid)

but this creates a mishmash array type like (string | number)[], so that a data[0] is still not decisively a string.

So neither every nor filter do the trick of allowing the compiler to strip the Maybe afterwards, but I wonder if there is a summarily, "loopy" way which avoids a cumbersome if(!isValid(a)... sequence.


Solution

  • There is currently no way to use a custom type guard function to narrow just the contents or members of a value.

    Custom type guard functions, which return a type predicate of the form arg is Type or this is Type, only narrow the apparent type of the specific argument corresponding to arg or the object corresponding to this. Any effects of narrowing would only be seen through subsequent accesses of the identical value. Custom type guard functions do not separately affect the apparent types of values which were assigned to members of arg or this. For example:

    declare function g(
      x: { a: RegExp | Date }
    ): x is { a: Date };
    const a = Math.random()<0.5 ? new Date() : new RegExp("")
    //    ^? const a: RegExp | Date
    const x = { a };
    //    ^? const x: { a:RegExp | Date; }
    if (g(x)) {
      x.a;
      //^? (property) a: Date
      a;
    //^? const a: RegExp | Date
    }
    

    In the above, calling g(x) has the effect of narrowing the apparent type of x to {a: Date}, and therefore narrowing the apparent type of x.a to Date. But a, a reference to the same value as x.a, has not been narrowed.

    That means it is essentially useless to use a custom type guard function on an anonymous object literal or an array literal:

    if (g({ a })) {
      a;
      //^? const a: RegExp | Date
      ({ a }).a
      //      ^? (property) a: RegExp | Date
    }
    

    The object literal { a } cannot be accessed again; it's anonymous. Writing { a } a second time just gives us a new object. And we know that a itself doesn't get narrowed. So we're stuck.

    There's an open feature request at microsoft/TypeScript#46184 asking for support to narrow object contents (specifically the destructured variables of object literals; presumably for array literals as well), but for now it's not part of the language.

    --

    You were trying to narrow an array literal [a, b, ⋯] with the type guard call signature for the every() method, but as we have just seen, this wouldn't have any effect:

    const now = new Date().getTime();
    const a: Maybe<string> = now > 1 ? 'a' : new Error;
    const b: Maybe<number> = now > 1 ? 1 : new Error;
    const c: Maybe<null> = now > 1 ? null : new Error;
    if ([a, b, c].every(isValid)) {
      a.toUpperCase(); // error, Maybe<string> 😢
    }
    

    Okay, well, what if we assign the array literal to a variable first and access the members through indices later? That fixes the problem with being anonymous:

    const arr = [a, b, c];
    // const arr: (string | number | Error | null)[]
    if (arr.every(isValid)) {
      const newA = arr[0]; // const newA: string | number | null
    }
    

    So, we get narrowing, hooray. Kind of. Unfortunately the array arr is of type Maybe<string | number | null>[]. So even though we've narrowed arr[0] to string | number | null, we've completely lost track of the order of the inputs. That brings us to our next problem:


    To keep track of the order of arr's elements, we'd need it to have a tuple type instead. And every doesn't act as an element-wise type guard on tuples:

    const tup = [a, b, c] as const;
    // const tup: readonly [Maybe<string>, Maybe<number>, Maybe<null>]
    if (tup.every(isValid)) {
      tup // const tup: readonly [Maybe<string>, Maybe<number>, Maybe<null>] 😟
    }
    

    And there's no way to fix that. TypeScript currently lacks the expressiveness necessary to describe how to perform element-wise type guards on tuples. It would require some way of talking about an arbitrary generic type F at the type level, maybe like:

    // NOT VALID TS, DO NOT TRY THIS
    function every<T extends any[], F extends 🤷‍♂️>(
      arr: T, cb: <U extends T[number]>(v: U, i: number, a: T) => value is F<U>
    ): {[I in keyof T]: F<T[I]>}
    

    But you can't do that. F is a generic type parameter that behaves like a specific type; you can't instantiate it with its own type arguments like F<U> or F<T[I]>. There is a longstanding open issue at microsoft/TypeScript#1213 but it's not part of the language. There are various ways to try to simulate/encode such types, but they are not user-friendly enough to suggest here.

    See Mapping tuple-typed value to different tuple-typed value without casts for more details about a similar issue.

    So we'll have to give up on using some kind of general type guard mapper that takes isValid as an input, and just go ahead and hardcode isValid() in there.


    If we want to use an array, we can write

    function isEveryElementValid<T extends readonly any[]>(
      values: { [K in keyof T]: Maybe<T[K]> }): values is T {
      return values.every(isValid);
    }
    

    and then use it like

    if (isEveryElementValid(tup)) {
      const realA = tup[0]; // const realA: string
      const realB = tup[1]; // const realB: number
      const realC = tup[2]; // const realC: null
    }
    

    Or if we want to use an object, we can write

    function isEveryPropertyValid<T extends object>(
      values: { [K in keyof T]: Maybe<T[K]> }): values is T {
      return Object.values(values).every(isValid);
    }
    

    and then use it like

    const o = {a,b,c};
    if (isEveryPropertyValid(o)) {
      const realA = o.a; // string
      const realB = o.b; // number
      const realC = o.c; // null
    }
    

    Playground link to code