Search code examples
javascriptnode.jstypescripteventemittertypesafe

Typescript type-safe event listener


So, I got this pseudo-code for a EventListener just for trying out the types and etc, but I didn't manage to find a way to make typescript change payload parameter into a specific type depending on event parameter value.

type READY = {
  A: boolean;
};

type CONNECTING = {
  B: boolean;
};

type DispatcherPayloads = {
  READY: READY;
  CONNECTING: CONNECTING;
};

type DispatcherEvents = keyof DispatcherPayloads;

type EmitterEventsListeners = {
  dispatch: <DispatcherEvent extends DispatcherEvents>(
    event: DispatcherEvent,
    payload: DispatcherPayloads[DispatcherEvent]
  ) => void;
};

type EmitterEventsNames = keyof EmitterEventsListeners;

function on<EmitterEventName extends EmitterEventsNames>(
  event: EmitterEventName,
  listener: EmitterEventsListeners[EmitterEventName]
) {
  listener("READY", { A: true })
}

on("dispatch", (event, payload) => {
  if(event === "READY") {
    console.log(payload.A) // COMPILER GIVES ERROR
    /**
      Property 'A' does not exist on type 'READY | CONNECTING'.
      Property 'A' does not exist on type 'CONNECTING'.(2339)
    **/
  }
})

I tried everything on my knowledge but it seems impossible. Would I need to do it in alternative way? I thought of some but I preferred sticking into this one but it seems not possible.


Solution

  • The problem is that event and payload are generic types and there is currently no way for the compiler to narrow the type of payload by checking the value of event.

    We can solve this by using discrimanted unions. By using a mapped type, we can construct a union of all valid event and payload combinations. The resulting tuple union can be properly discriminated by the compiler based on the event type.

    You need to modify EmitterEventsListeners to look like this:

    type EmitterEventsListeners = {
      dispatch: (
        ...args: { 
            [K in keyof DispatcherPayloads]: [event: K, payload: DispatcherPayloads[K]] 
        }[keyof DispatcherPayloads]
      ) => void;
    };
    

    The resulting type of ...args is now

    args: 
     | [event: "READY", payload: READY] 
     | [event: "CONNECTING", payload: CONNECTING]
    

    which solves the narrowing issue.

    on("dispatch", (event, payload) => {
      if(event === "READY") {
        console.log(payload.A)
      } else if (event === "CONNECTING") {
        console.log(payload.B)
      }
    })
    

    Playground