1

I'm trying to create a map in Typescript of events to their corresponding event handlers in a strongly typed way.

class EventA extends Event {}
class EventB extends Event {}

function handleA(eventA: EventA): void {}
function handleB(eventB: EventB): void {}

// help me with typing EventToHandlerMap

// I want this to pass
const map: EventToHandlerMap = new Map([
  [EventA, handleA],
  [EventB, handleB]
]);

// I want this to fail
const map: EventToHandlerMap = new Map([
  [EventA, handleB],
  [EventB, handleA]
]);

Is there a way to model a relationship between the key and value in every entry of the Map

alaboudi
  • 3,187
  • 4
  • 29
  • 47
  • you are mixing both key types and value types – Mulan May 29 '23 at 20:43
  • @Mulan whoops I got the terminology wrong. So put in a proper way, I want to know how I can have a type that relates the key and value in each entry of the Map – alaboudi May 29 '23 at 20:50
  • There's no built in way to do this, you'd need to write a fairly complicated version of the Map interface typing as shown [in this playground link](https://tsplay.dev/N5xo0w). Does that meet your needs? If so I'll write up an answer explaining; if not, what am I missing? (Also note I added structure to `EventA` and `EventB` otherwise they would be structurally identical to each other and therefore indistinguishable to TS's type system) – jcalz May 29 '23 at 20:52
  • @jcalz Thank you so much for the playground link. It helped a lot. I did a small typo in my question. I need to map the class definition to its handler rather than instance. Think you can help with that? Sorry for the mistake. I'll update the question now – alaboudi May 30 '23 at 00:55
  • 1
    Okay then does [this version](https://tsplay.dev/Nr9k1w) meet your needs? If so I'll write up an answer, otherwise, what's wrong with it? – jcalz May 30 '23 at 01:06
  • yes, I think your type cast solution does it very well. The only note I have is that I think the `ObjectToEntries` variable isn't used anywhere in the playground. Aside from that, it works as expected. Thank you so much – alaboudi May 30 '23 at 14:34
  • 1
    Yeah I'll get rid of that, it was from a different solution. I'll write up an error when I get a chance. – jcalz May 30 '23 at 14:35

1 Answers1

1

This is similar to Typescript: How can I make entries in an ES6 Map based on an object key/value type and the answer here is similar to my answer there, but they are different enough that this question and answer isn't a direct duplicate.


The TypeScript typings for Map<K, V> don't reflect any more specific relationship between keys and values than "the keys are of type K and the values are of type V". If you need something more specific, you'll need to write your own typings for it. That involves looking at the library files and modifying them.

In your case you want each key to be an Event constructor like

type EventCtor<T extends Event> = new (...args: any) => T;

and each value to be a handler for the corresponding event like

type Handler<T extends Event> = (event: T) => void;

So we will need to write an EventToHandlerMap that behaves like some more specific version of EventToHandlerMap<EventCtor<any>, Handler<any>>:

interface EventToHandlerMap {
  forEach(callbackfn: <T extends Event>(
    value: Handler<T>, key: EventCtor<T>, map: EventToHandlerMap
  ) => void, thisArg?: any): void;
  get<T extends Event>(key: EventCtor<T>): Handler<T> | undefined;
  set<T extends Event>(key: EventCtor<T>, value: Handler<T>): this;
  readonly size: number;
}

That hopefully works how you need it; get() and set() for example are generic in T extends Event so that the key and value are related.

Then we'll need to define the Map constructor in a similar way, so that the compiler knows how to interpret the constructor arguments:

interface EventToHandlerMapConstructor {
  new <T extends Event[]>(
    entries: [...{ [I in keyof T]: [k: EventCtor<T[I]>, v: Handler<T[I]>] }]
  ): EventToHandlerMap;
  new(): EventToHandlerMap;
  readonly prototype: EventToHandlerMap;
}

const EventToHandlerMap = Map as EventToHandlerMapConstructor;

Note how I've just made EventToHandlerMap an alias of Map and asserted that it behaves as an EventToHandlerMapConstructor. That assertion is necessary because the compiler doesn't know that Map will work that way.

Once we do that, you can use EventToHandlerMap as your constructor and get the desired behavior:

const map: EventToHandlerMap = // okay
  new EventToHandlerMap([[EventA, handleA], [EventB, handleB]]);

const map2: EventToHandlerMap =
  new EventToHandlerMap([[EventA, handleB], [EventB, handleA]]); // error
  //                              ~~~~~~~  <-------> ~~~~~~~
  // Type '(eventB: EventB) => void' is not assignable to type 'Handler<EventA>'.
  // Type '(eventA: EventA) => void' is not assignable to type 'Handler<EventB>'.

map.get(EventA)?.(new EventA("")); // okay
map.get(EventB)?.(new EventB("")); // okay

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360