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
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 push
ing 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 (