arraystypescriptnarrowing

Narrowing a union of array types using Array.every


I have a variable with a type that is a union of different array types. I want to narrow its type to a single member of that union, so I can run the appropriate code over it for each type. Using Array.every and a custom type guard seems like the right approach here, but when I try this TypeScript complains that "This expression is not callable." along with an explanation that I don't understand.

Here is my minimum reproducible example:

const isNumber = (val: unknown): val is number => typeof val === 'number';

const unionArr: string[] | number[] = Math.random() > 0.5 ? [1, 2, 3, 4, 5] : ['1', '2', '3', '4', '5'];

if (unionArr.every(isNumber)) { // <- Error
  unionArr;
}

TypeScript Playground

Here is the error:

This expression is not callable.
  Each member of the union type
    '{
      <S extends string>(predicate: (value: string, index: number, array: string[]) => value is S, thisArg?: any): this is S[];
      (predicate: (value: string, index: number, array: string[]) => unknown, thisArg?: any): boolean;
    } | {
      ...;
    }'
  has signatures, but none of those signatures are compatible with each other.

This isn't preventing me from continuing. I've found that using a type assertion to recast my array to unknown[] before I narrow its type, as I would do if I were writing an isNumberArray custom type guard, removes the error without compromising type safety.

I've also found that recasting my string[] | number[] array to (string | number)[] removes the error.

However, the type of the array doesn't seem to be narrowed correctly, so I would need to use an additional as number[] after the check:

const isNumber = (val: unknown): val is number => typeof val === 'number';

const unionArr: string[] | number[] = Math.random() > 0.5 ? [1, 2, 3, 4, 5] : ['1', '2', '3', '4', '5'];

if ((unionArr as unknown[]).every(isNumber)) { // <- No error
  unionArr; // <- Incorrectly typed as string[] | number[]
}

if ((unionArr as (string | number)[]).every(isNumber)) { // <- No error
  unionArr; // <- Incrrectly typed as string[] | number[]
}

TypeScript Playground

I tried a comparison with a non-array union as well, though of course in this case I was just using the custom type guard directly instead of using it with Array.every. In that case, there was also no error and the type was narrowed correctly:

const isNumber = (val: unknown): val is number => typeof val === 'number';

const union: string | number = Math.random() > 0.5 ? 1 : '1';

if (isNumber(union)) {
  union; // <- Correctly typed as number
}

TypeScript Playground

Because I have that safe type assertion workaround, I can continue without needing to understand this. But I'm still very confused as to why that error appears in the first place, given I am trying to narrow a union of types to a single member of that union.

I'm guessing this is something to do with how Array.every has been typed by TypeScript, and there's probably nothing I can do aside from the workaround I'm already using. But it's hard to be sure of that when I don't really understand what's going wrong. Is there something I could do differently here, or is the as unknown[] type assertion I've used the correct or best way to handle this?


Solution

  • Let's take a simpler example:

    type StringOrNumberFn = (a: string | number) => void
    type NumberOrBoolFn = (a: number | boolean) => void
    declare const justNumber: StringOrNumberFn | NumberOrBoolFn
    
    // works
    justNumber(123) 
    
    // errors
    justNumber('asd')
    justNumber(true)
    

    When you have a union of functions that you try to invoke, you are actually calling a function that is the intersection of those members. If you don't know which function it is, then you may only call that function in ways that both functions support. In this case, both functions can take a number, so that's all that's allowed.

    And if the intersection of those functions would have incompatible arguments, then the function cannot be called. So let's model that:

    type StringFn = (a: string) => void
    type NumberFn = (a: number) => void
    declare const fnUnion: StringFn | NumberFn
    
    fnUnion(123)    // Argument of type 'number' is not assignable to parameter of type 'never'.(2345)
    fnUnion('asdf') // Argument of type 'string' is not assignable to parameter of type 'never'.(2345)
    

    This is closer to your problem.

    Playground


    A string array's every and a number array's every are typed to receive different parameters.

    (value: string, index: number, array: string[]) // string[] every() args
    (value: number, index: number, array: number[]) // number[] every() args
    

    Which is essentially the same problem as above.


    So I don't think there's a way that the compiler will be okay with calling the every method on this union.

    Instead, I'd probably write a type assertion for the whole array and loop over it manually.

    const isNumberArray = (array: unknown[]): array is number[] => {
      for (const value of array) {
        if (typeof value !== 'number') return false
      }
    
      return true
    }
    
    declare const unionArr: string[] | number[]
    
    if (isNumberArray(unionArr)) {
      Math.round(unionArr[0]); // works
    }
    

    Playground