Search code examples
typescripttypescript-generics

Narrow down generic type whose parameter is type of function argument


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?


Solution

  • 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.

    Playground link to code