typescriptgenerics

Why does TS infer return type differently between `reduce` and `map` for `unknown[]`?


const array: unknown[] = []
const a = array.map(() => '') // a is string
const b = array.reduce((acc) => acc, 'initial') // b is unknown (why?)

For the map call above, TS inferred the following signature:

map<U>(callbackfn: (value: T, index: number, 
    array: T[]) => U, thisArg?: any): U[];

which makes sense (U is string, T is unknown).

But for the reduce call, TS inferred:

reduce(callbackfn: (previousValue: T, currentValue: T, 
    currentIndex: number, array: T[]) => T, initialValue: T): T;

(generalizing the return type to T = unknown) instead of inferring

// This signature is also available in lib.es5.d.ts
reduce<U>(callbackfn: (previousValue: U, currentValue: T, 
   currentIndex: number, array: T[]) => U, initialValue: U): U;

Why didn't TS narrowed the inferred types of the reduce call to T = unknown and U = string?


Solution

  • A function/method with multiple call signatures is overloaded. When you call such a function, TypeScript resolves the function call by selecting one of the call signatures. Generally speaking it selects the first call signature that matches the arguments. That is, the call signatures in overloads are ordered and the order of overloads affects which call signature has higher priority.

    The typings for Array<T>.reduce() in the TS standard library look like

        reduce(cb: (prev: T, curr: T, idx: number, arr: T[]) => T): T;
        reduce(cb: (prev: T, curr: T, idx: number, arr: T[]) => T, init: T): T;
        reduce<U>(cb: (prev: U, curr: T, idx: number, arr: T[]) => U, init: U): U;
    

    If you call reduce() with two arguments then the first overload cannot succeed, so it has to choose either the second or the third. If the type of init is assignable to T, then the second overload will succeed and is chosen. In your case T is the unknown type, to which everything is assignable, so it pretty much guarantees that the second call signature is selected. The third, generic, call signature is not even tried.

    So that's why you see this behavior. The map() method typings on the other hand have just one call signature, and it's generic.


    If you want to call reduce() so that the third call signature is chosen, you need to change how you call it so that it's not compatible with the first two call signatures. The easiest way to do this is to just specify a generic type argument, since neither of the first two call signatures declares a generic type parameter:

    const b = array.reduce<string>((acc) => acc, 'initial') // string
    

    There are other approaches, like manually annotating the prev callback parameter so that T is not assignable to it, since functions are contravariant in their parameter types (see Difference between Variance, Covariance, Contravariance, Bivariance and Invariance in TypeScript), and thus narrowing prev widens the type of cb so it no longer matches the second overload:

    const c = array.reduce((acc: string) => acc, 'initial') 
    

    But these workarounds are outside the scope of the question as asked so I'll stop here.

    Playground link to code