Search code examples
typescriptgenericstyping

How to strongly type an event emitter such that the handler's parameter type is inferred from the event type?


I'm trying to define a strongly typed event-emitter, what I mostly want is to have the callback's event type inferred from the string passed to the addEventHandler function.

But I've failed so far, and what I came up with infers the event type from the callback, not the opposite.

Here's an example (with a fiddle):

interface NumberEvent {
  type: 'NumberEvent';
  num: number;
}

interface StringEvent {
  type: 'StringEvent';
  str: string;
}

type AnyEvent = NumberEvent | StringEvent;

const addEventHandler = <ET extends AnyEvent>(type: ET['type'], handler: ((event: ET) => void)) => {
  console.log(`added event handler for ${type}`);
}

addEventHandler('NumberEvent', (event: NumberEvent) => {
  // this is cool
});

addEventHandler('NumberEvent', (event: StringEvent) => {
  // this doesn't type check, good
});

addEventHandler('type does not exist', (x: any) => {
  // why no type error?
});

I do not understand why the last line type-checks, because there is no instance of AnyEvent with type 'type does not exist'.

Can you think of a better approach to the problem?


Solution

  • You can achieve this by making addEventHandler generic on the event type, rather than the event object.

    const addEventHandler = <ET extends AnyEvent['type']>(
      type: ET,
      handler: ((event: Extract<AnyEvent, { type: ET }>) => void)
    ) => {
      console.log(`added event handler for ${type}`);
    }
    

    You could also use AnyEvent & { type: ET } instead of Extract<AnyEvent & { type: ET }

    The reason your type doesn't prevent the last case is because ET is inferred as any. any["type"] is still any, so it will allow any string at all.

    The above version still won't prevent someone from doing this:

    addEventHandler<any>('type does not exist', (x: any) => {
      // explicitly providing <any>
    });
    

    You can prevent this, by using the fact that <anything> & any is any, but personally I wouldn't bother. Nobody is likely to provide any here unless intentionally trying to break your types. With the any check, you can also go back to your generic:

    type NotAny<T> = 0 extends (1 & T) ? never : T; 
    
    const addEventHandler = <ET extends AnyEvent>(type: NotAny<ET["type"]>, handler: ((event: ET) => void)) => {
      console.log(`added event handler for ${type}`);
    }