arraystypescriptunion-typesdiscriminated-union

Type narrowing for discriminated arrays


If I have type like string[] | number[] is there a way to narrow type to one of these array types without explicit function that returns like T is number[]?

I tried this but typescript (5.5.4 is the latest now) doesn't get it: playground

declare const arr : string[] | number[]

if (arr.length) {
  if (typeof arr[0] === 'string') {
    // Property 'substr' does not exist on type 'string | number'.
    //   Property 'substr' does not exist on type 'number'
    arr.map(x => x.substr(1))
  } else {
    // Operator '+' cannot be applied to types 'string | number' and 'number'.
    arr.map(x => x + 1)
  }
}

Solution

  • TypeScript doesn't generally narrow an object type when you narrow one of its properties. There is an open feature request for this behavior at microsoft/TypeScript#42384, but for now it's not part of the language. Currently the only way you can narrow the type of an object by checking a property is if the object's type is a discriminated union and you check a discriminant property. But while string[] | number[] is a union, it's not a discriminated union. The discriminant property must be a literal type like "abc" or 123 or true (or at least one of the constituents of the union needs to have such a literal type). Neither string nor number are literal types, so checking typeof arr[0] === "string" doesn't narrow arr.


    For now if you want narrowing to happen you'll need to write a type guard function. One way to do this is to emulate the general desired behavior of narrowing an object by checking a property:

    function unionPropGuard<T, K extends keyof T, U extends T[K]>(
      obj: T, key: K, guard: (x: T[K]) => x is U):
      obj is T extends unknown ? (T[K] & U) extends never ? never : T : never {
      return guard(obj[key])
    }
    

    This will extract all elements of the union T whose K property has overlap with the guarded type U. Now you can rewrite if (guard(obj[prop])) to if (unionPropGuard(obj, prop, guard)). In your example code that looks like:

    if (arr.length) {
      if (unionPropGuard(arr, 0, x => typeof x === "string")) {
        arr.map(x => x.substring(1))
      } else {
        arr.map(x => x + 1)
      }
    }
    

    Note that x => typeof x === "string" is inferred to be a type guard function of type (x: string | number) => x is string. So the call to unionPropGuard() performs the narrowing you were trying to perform by writing typeof arr[0] === "string". If it returns true then arr is narrowed to string[], otherwise arr is narrowed to number[].

    Playground link to code