Typescript index type composition

Viewed 58

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:)

1 Answers

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

Related