3

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?

djfm
  • 2,317
  • 1
  • 18
  • 34
  • Please see [my article](https://catchts.com/publish-subscribe) it should help. Related questions [here](https://stackoverflow.com/questions/65668969/event-maps-and-type-guards#answer-65890181) and [here](https://stackoverflow.com/questions/70137328/mapping-a-variable-number-of-generics-while-retaining-link-between-type-values#answer-70138046) – captain-yossarian from Ukraine Jan 01 '22 at 20:13
  • 1
    Oh but! I had already started reading your site before, what a small world stackoverflow is! Thanks a lot, I have tons of things to read on my todo list but I'll get there eventually. – djfm Jan 02 '22 at 01:27

1 Answers1

1

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}`);
}
Gerrit0
  • 7,955
  • 3
  • 25
  • 32
  • Awesome!! Thanks a lot for the solution and for the explanations. I had tried something similar but I must have messed up, I wasn't sure how to perform this "extraction", now I know. And yeah if somebody decides to explicitly pass any... well, there's not much I can do for them at this point, it's their problem. – djfm Jan 01 '22 at 23:42