Apologies if this is a duplicate, I found some possibly similar questions with answers, but couldn't adapt them to my problem.
I have a function that accepts a string parameter which is constrained to be one of several literal strings (union type, call it T
). I also have a type that's a 1 to 1 mapping of T
to another type, call it M<T>
. I want to define an array variable whose elements are type M<T>
but have it narrowed down by checking the value of the parameter of type T
, so that typescript understands that all elements are of a fixed M<T>
.
Anyway, here's the example that makes this clear:
type GestureDevice = "key" | "pointer";
type EventTypes = {
"key": KeyboardEvent;
"pointer": PointerEvent;
};
const k = (e: KeyboardEvent[]) => {}
const p = (e: PointerEvent[]) => {}
const func = (device: GestureDevice, events: Event[]) => { // making it generic in GestureDevice only worsens things
const deviceC = device; // doesn't help
const arr : Array<EventTypes[typeof deviceC]> = []; // problem is this is Array<KeyboardEvent | PointerEvent>
for (const e of events) {
// why can't this tell TS that all elements are the same type, since dC is constant
// and then when dC is checked later on, arr could be narrowed down
if (isEventTypeOf(deviceC, e)) {
arr.push(e);
}
}
if (deviceC === "pointer") { // adding && isEventTypeOf(arr[0]) doesn't help
p(arr as PointerEvent[]); // errors without the asserttion...
} else if (deviceC === "key" && areEventsTypeOf(deviceC, arr)) {
k(arr); // this is OK thanks to areEventsTypeOf, but I want to avoid it
}
}
const isEventTypeOf = <D extends GestureDevice>(device: D, event: Event, ): event is EventTypes[D] => {
if (device === "key") {
return event instanceof KeyboardEvent;
} else if (device === "pointer") {
return event instanceof PointerEvent;
}
return false;
}
const areEventsTypeOf = <D extends GestureDevice>(device: D, events: Event[]): events is [EventTypes[D]] => {
for (const e of events) {
if (! isEventTypeOf(device, e)) {
return false;
}
}
return true;
}
My problem is line 22. I don't want to use an assertion and I don't want to be checking all elements in the array given it's pointless because they are checked before being inserted (line 16).
I've tried all sorts of variations (e.g. this or this) of defining the function func
making it generic and I haven't found a way to define the type of the array as I need it.
Is there a way have typescript infer the type of the array's elements based on the value of device
?
The issue is that TypeScript can't deal with so-called correlated union types as discussed in microsoft/TypeScript#30581. The GestureDevice
type is a union, and by construction you want both device
and arr
to refer to the same member of the union. That is, if device
is "key"
then arr
is Array<EventTypes["key"]>
, and if device
is "pointer"
then arr
is Array<EventTypes["pointer"]>
. But TypeScript has no way to keep track of such correlations.
The recommended approach when dealing with correlated unions is to refactor to use generics instead, as described in microsoft/TypeScript#47109. It involves using a basic mapping type (and your EventTypes
is already such a mapping type), mapped types into that mapping type, and generic indexes into that type. You'll also need to refactor so as not to rely on control flow analysis, so instead of a switch
/case
or if
/else
, you'll need to perform a generic indexing into an object. Like this:
const handlers: { [K in keyof EventTypes]: (e: EventTypes[K][]) => void } = {
key: k,
pointer: p
}
const func = <K extends keyof EventTypes>(device: K, events: Event[]) => {
const arr: Array<EventTypes[K]> = [];
for (const e of events) {
if (isEventTypeOf(device, e)) {
arr.push(e);
}
}
handlers[device](arr);
}
The handlers
object represents the if
/else
block; we've written it as a mapped type over EventTypes
, where each member with key K
is a function accepting an array of EventTypes[K]
. Then func
is made generic, where device
is of generic type K
constrained to GestureDevice
, and arr
is of type Array<EventTypes[K]>
.
The "magic" happens with handlers[device](arr)
, since that's the correlated thing. It works because handlers[device]
is seen as a single function of type (e: EventTypes[K]) => void
, and arr
is of type EventTypes[K]
. So the call is allowed and it does what you want.
Note that the explicit annotation of handlers
with a mapped type over EventTypes
's properties is very important. If you remove the annotation, everything has the same type, but TypeScript can't follow the logic. TypeScript needs to see that handlers
does a single thing for generic/arbitrary K
. It cannot analyze the value {key: k, pointer: p}
and "see" that relationship. You need to explicitly write it as a mapped type.