How to tell TypeScript that two generic types are the same?

Viewed 65

Consider the following overloaded function.

function scan<A>(this: A[], f: (a: A, x: A) => A): A[];
function scan<A, B>(this: A[], f: (a: B, x: A) => B, init?: B): B[] {
    if (init === undefined) {
        const result = [this[0]];
        for (let i = 1; i < this.length; i++) {
            result.push(f(result[i - 1], this[i]));
        }
        return result;
    }

    const result = [init];
    for (let i = 0; i < this.length; i++) {
        result.push(f(result[i], this[i]));
    }
    return result;
}

Note that when init is not provided, the generic type B should be the same as A. How do I tell this to TypeScript? Currently, TypeScript complains that A is not assignable to B and vice versa.

2 Answers

An overloaded function has a set of call signature declarations which determine how the function can be called, and (assuming the function is implemented and not just declared) a single implementation. The implementation signature is not callable.


In your example code, you have a single call signature

// call signature
function scan<A>(this: A[], f: (a: A, x: A) => A): A[];

and an implementation

// implementation
function scan<A, B>(this: A[], f: (a: B, x: A) => B, init?: B): B[] {
    /* snip */
}

But this does not seem to be what you intend. You really want those signatures both to be call signatures, like this:

// call signatutes
function scan<A>(this: A[], f: (a: A, x: A) => A): A[];
function scan<A, B>(this: A[], f: (a: B, x: A) => B, init?: B): B[];

// implementation
function scan(...) {

So the question is: what should the implementation signature be?


TypeScript's compiler is not able to check the implementation by checking it against each call signature separately. There was a suggestion to do this at microsoft/TypeScript#13235, but it was closed as too complex to implement. Instead, what the compiler does is make sure that the implementation signature parameters can handle the parameters from each of the call signatures, and make sure that the implementation signature return type can handle the return return types from each of the call signatures. That is, the return type can be the union of all the call signatures' return types. This is not type safe (because you might be returning the wrong type for a particular call signature), but convenient.

For better or worse, such loose checking is the way TypeScript's overload implementation works. So you need to be careful when you write overloaded functions.


Anyway, that means the implementation needs to be something like this:

// implementation signature
function scan<A, B>(this: A[], f: (a: B | A, x: A) => A, init?: B | A) {
    if (init === undefined) {
        const result = [this[0]];
        for (let i = 1; i < this.length; i++) {
            result.push(f(result[i - 1], this[i]));
        }
        return result;
    }

    const result = [init];
    for (let i = 0; i < this.length; i++) {
        result.push(f(result[i], this[i]));
    }
    return result;
}

It's not perfect by any means, but it's probably the best we can get if we want to put these two separate behaviors into a single overloaded function.

Playground link to code

I was able to get this to type check by weakening the implementation signature. Lesson learned, the implementation signature must always be a combination of the overloaded signatures.

const SCAN_EMPTY_NO_INIT = 'Scan of empty array with no initial value';

type Reducer<A, B> = (acc: B, a: A) => B;

function scan<A>(this: A[], f: Reducer<A, A>): A[];
function scan<A, B>(this: A[], f: Reducer<A, B>, init: B): B[];
function scan<A, B>(this: A[], f: Reducer<A, A | B>, init?: B): (A | B)[] {
    const { length } = this;
    let i = 0, result = [typeof init === 'undefined' ? this[i++] : init];
    if (length < i) throw new TypeError(SCAN_EMPTY_NO_INIT);
    const j = i;

    while (i < length) {
        result.push(f(result[i - j], this[i]));
        i++;
    }

    return result;
}

Note that the function can't be called with the implementation signature. Hence, you can't use this function in unintended ways, to create an array of As or Bs.

Related