Search code examples
typescriptgenericsindexingtypescomposition

Typescript index type composition


I am trying to type my event API as such:

type Apple = {
    seeds: number;
}
type Peach = {
    weight: number;
}

interface AddAppleEvent {
    apple: Apple;
}

interface AddPeachEvent {
    peach: Peach;
}

interface EventMap {
    "addApple": AddAppleEvent;
    "addPeach": AddPeachEvent;
}


class EventApi{
    on<K extends keyof EventMap>(type: K, listener: (ev: EventMap[K]) => any): void{    
    }
}

const api = new EventApi();
api.on("addApple", (evt) => {
    console.log(evt.apple);
});

This works wonder: Typescript knows what is in my evt variable from the name of the event I am registering on.

But: I would like ot be able to register to an array of events, and type the result (which will be a composition of the types of each events):

api.on(["addApple", "addPeach"], ({apple, peach}) => {
    if(apple) {
     // do something
    }
    if(peach) {
     // do something else
    }

})

Unfortunately I cant find a way to iterate over an array of keys from an interface to build an union of all the values referred.

Any idea ?

Here is a link to the typescript playground with the example above

-----EDIT----

Just summing up the great answers I got for this post:

Thanks a lot guys:)


Solution

  • I'd start by defining EventMapKey just to avoid retyping keyof EventMap:

    type EventMapKey = keyof EventMap;
    

    Then it depends on whether you want a union or an intersection.

    Union

    You can define onMultiple like this:

    onMultiple<K extends EventMapKey[], EventType extends EventMap[K[number]]>(type: K, listener: (ev: EventType) => any): void {
        console.log("on add multiple event listenner", listener);
    }
    

    Playground link - I added a third event type (just to be sure), and the example usage of onMultiple at the end works:

    api.onMultiple(["addApple", "addPeach"], (evt) => {
        // Here, evt is AddAppleEvent | AddPeachEvent
    });
    

    Naturally, before you can use the apple or peach properties of evt, you have to do a type guard to check what kind of event you're dealing with. To aid with that, I'd probably add a type to the event types so you can work from that:

    interface AddAppleEvent {
        type: "addApple";
        apple: Apple;
    }
    
    interface AddPeachEvent {
        type: "addPeach";
        peach: Peach;
    }
    
    api.onMultiple(["addApple", "addPeach"], (evt) => {
        if (evt.type === "addApple") {
            console.log(evt.apple.seeds);
        } else {
            console.log(evt.peach.weight);
        }
    });
    

    Playground link

    But you can also work from "apple" in evt without adding type if you like.

    api.onMultiple(["addApple", "addPeach"], (evt) => {
        if ("apple" in evt) {
            console.log(evt.apple.seeds);
        } else {
            console.log(evt.peach.weight);
        }
    });
    

    Playground link

    Intersection

    If you want an intersection instead, here's the solution jcalz posted in a comment:

    type ComposedEventListener<K extends EventMapKey[]> = {
        [I in keyof K]: (ev: EventMap[Extract<K[I], EventMapKey>]) => any
    }[number] extends (ev: infer I) => any ? (ev: I) => any : never
    
    class EventApi {
    
        on<K extends EventMapKey>(type: K, listener: (ev: EventMap[K]) => any): void {
    
            console.log("on add event listenner", listener);
        }
    
        // here, write K as a set of keys from EventMap allows to pass multiple keys,
        // but I cant find how to type the listener properlly.
        onMultiple<K extends EventMapKey[]>(type: readonly [...K], listener: ComposedEventListener<K>): void {
            console.log("on add multiple event listenner", listener);
        }
    
    }
    
    const api = new EventApi();
    api.on("addApple", (evt) => {
        console.log(evt.apple);
    });
    
    api.onMultiple(["addApple", "addPeach"], (evt) => { });
    

    Playground link