Home > Software design >  Generic type is collapsed to union when passed to another function
Generic type is collapsed to union when passed to another function

Time:12-30

Hello fellow StackOverflowers!

I have been trying for quite some time (with more complicated code) to achieve a generic type safe callback system that where one can register a callback to some event and this callback is added to an array of listeners for the specific event type.

It works fine when I don't use an array, but just one callback per event. But typescript gets lost when I try to use an array instead to push the callback into.

enum MyEvent {
    One,
    Two
}

type Callback<T> = (arg: T) => void;

type CallbackTypes = {
    [MyEvent.One]: number
    [MyEvent.Two]: string
}

class CallbackContainer {
    callbacksA: { [E in MyEvent]: Callback<CallbackTypes[E]> } = {
        [MyEvent.One]: () => {},
        [MyEvent.Two]: () => {}
    };

    callbacksB: { [E in MyEvent]: Callback<CallbackTypes[E]>[] } = {
        [MyEvent.One]: [],
        [MyEvent.Two]: []
    };

    constructor() {}
    
    setListenerA<E extends MyEvent, C extends typeof this.callbacksA[E]>(this: CallbackContainer, event: E, callback: C) {
        this.callbacksA[event] = callback; // =)
    }

    getListenerA<E extends MyEvent>(event: E) {
        return this.callbacksA[event];
    }

    setListenerB<E extends MyEvent, C extends typeof this.callbacksB[E][number]>(this: CallbackContainer, event: E, callback: C) {
        const callbacks = this.callbacksB[event]; // type of callbacks is still intact
        callbacks.push(callback); // callbacks collapsed
    }

    getListenersB<E extends MyEvent>(event: E) {
        return this.callbacksB[event];
    }
}

const c = new CallbackContainer();

c.setListenerA(MyEvent.One, (a) => a   1); // Types resolve fine
c.getListenerA(MyEvent.One); // Types resolve fine

c.setListenerB(MyEvent.One, (a) => a   1); // Types resolve fine
c.getListenersB(MyEvent.One); // Types resolve fine

Link to Typescript Playground

When pushing the callback into the array I get:

Argument of type 'Callback<number> | Callback<string>' is not assignable to parameter of type 'Callback<number> & Callback<string>'.
  Type 'Callback<number>' is not assignable to type 'Callback<number> & Callback<string>'.
    Type 'Callback<number>' is not assignable to type 'Callback<string>'.
      Type 'string' is not assignable to type 'number'.ts(2345)

While before pushing, the compiler knows perfectly well that the type of C (the callback is):

C extends {
    0: Callback<number>[];
    1: Callback<string>[];
}[E][number]>

and the type of the callbacks array is:

const callbacks = {
    0: Callback<number>[];
    1: Callback<string>[];
}[E]

But when pushing they collapse to Callback<number> | Callback<string> and Callback<number>[] | Callback<string>[] respectively. Have I run into a limitation of the typescript compiler or am I missing something obvious? If it's a limitation, are there any workarounds? Thanks!

CodePudding user response:

For ease of discussion I'm going to look at the following version of your code:

type Callbacks = { [E in MyEvent]: Callback<CallbackTypes[E]>[] };

class CallbackContainer {

    callbacks: Callbacks = {
        [MyEvent.One]: [],
        [MyEvent.Two]: []
    };

    constructor() { }

    setListener<E extends MyEvent>(event: E, callback: Callback<CallbackTypes[E]>) { 
      /* how to implement? */ 
    }

}

which is pretty much the same, except that callback is of a properly generic type (in your version it gets widened to a union type). And it still has the same problem in TypeScript 4.5 and below:

// TS 4.5-
const callbacks = this.callbacks[event]
// const callbacks: Callbacks[E]
callbacks.push(callback); // error!
// const callbacks: Callback<number>[] | Callback<string>[]

When you call callbacks.push(), the type of callbacks loses its genericness (genericity? genericality? whatever) and is seen only as a union. And since both callbacks and callback are either of a union type or constrained to a union type, the compiler forgets that they are correlated to each other. It worries about impossible situations, such as where callbacks is a Callback<number>[] while callback is a Callback<string>.

This is, at least up until TypeScript 4.5, a design limitation (or missing feature) in TypeScript. See microsoft/TypeScript#30581 for a detailed discussion.


Luckily enough, a fix at microsoft/TypeScript#47109 should be released with TypeScript 4.6. Among other things, it maintains the genericosity (

  • Related