1

I have a factory function which creates a classic on() event listener, but one that is specific to whatever even types I want to allow the user to listen to. The events are defined as types and they have eventName and data (which is the data that is returned when the event emits). I would like to keep these two related to each-other, such that if I were to listen for a specific event then the relevant data will be available to me in the handler.

Consider this code:

interface DefaultEventBody {
  eventName: string
  data: unknown
}

interface ChristmasEvent extends DefaultEventBody {
  eventName: 'christmas',
  data: {
    numberOfPresentsGiven: number
  }
}

interface EasterEvent extends DefaultEventBody {
  eventName: 'easter',
  data: {
    numberOfEggsGiven: number
  }
}

export function Listener <EventBody extends DefaultEventBody> () {

  return (eventName: EventBody['eventName'], fn: (data: EventBody['data']) => void) => {
    // someEmitter.on(eventName, fn)
  }
}

const spoiltBrat = { on: Listener<EasterEvent|ChristmasEvent>() }

spoiltBrat.on('christmas', (data) => {

  console.log(data.numberOfPresentsGiven)

})

TypeScript rightly knows that the eventName I pass can be christmas|easter, but it is unable to infer the data type on the handler, and subsequently errors when I attempt to access data.numberOfPresentsGiven.

Property 'numberOfPresentsGiven' does not exist on type '{ numberOfPresentsGiven: number; } | { numberOfEggsGiven: number; }'. Property 'numberOfPresentsGiven' does not exist on type '{ numberOfEggsGiven: number; }'.(2339)

I'm aware of why this is happening (because neither of the types ChristmasEvent and EasterEvent contain the same numberOf* properties), but wondered if there was a solution to what I want to achieve?

Update

As per the request from Captain Yossarian, here is a Playground Link with the almost finished script.

shennan
  • 10,798
  • 5
  • 44
  • 79

1 Answers1

1

Quick fix

Please see this answer:

type UnionKeys<T> = T extends T ? keyof T : never;
type StrictUnionHelper<T, TAll> =
  T extends any
  ? T & Partial<Record<Exclude<UnionKeys<TAll>, keyof T>, never>> : never;

// credits goes to https://stackoverflow.com/questions/65805600/type-union-not-checking-for-excess-properties#answer-65805753
type StrictUnion<T> = StrictUnionHelper<T, T>

export function Listener<EventBody extends DefaultEventBody>() {

  return (eventName: EventBody['eventName'], fn: (data: StrictUnion<EventBody['data']> /** <---- change is here */) => void) => {
    // someEmitter.on(eventName, fn)
  }
}


const spoiltBrat = {
  on: Listener<EasterEvent | ChristmasEvent>()
}

spoiltBrat.on('christmas', (data) => {
  data.numberOfPresentsGiven // <---- DRAWBACK, number | undefined
})

Above solution works but it has its own drawbacks. As you might have noticed, numberOfPresentsGiven is allowed but it might be undefined. This is not what we want.

Longer fix

Usually, if you want to type publish/subscribe logic, you should go with overloadings. Consider this example:

type AllowedEvents = ChristmasEvent | EasterEvent

type Events = AllowedEvents['eventName']

// type Overloadings = {
//     christmas: (eventName: "christmas", fn: (data: ChristmasEvent) => void) => void;
//     easter: (eventName: "easter", fn: (data: EasterEvent) => void) => void;
// }
type Overloadings = {
  [Prop in Events]: (eventName: Prop, fn: (data: Extract<AllowedEvents, { eventName: Prop }>) => void) => void
}

Now we have a data structure with appropriate types of our on function. On order to apply this DS to on and make it act as overloadings, we need to obtain a union type of Overloadings props and merge them (intersection). Why intersection ? Because intersection of function types produces overlodings.

Let's obtain a union of values:

type Values<T>=T[keyof T]

type Union = 
| ((eventName: "christmas", fn: (data: ChristmasEvent) => void) => void) 
| ((eventName: "easter", fn: (data: EasterEvent) => void) => void)
type Union = Values<Overloadings>

Now, when we have a union, we can convert it to intersection with help of utility type:

// credits goes to https://stackoverflow.com/a/50375286
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
  k: infer I
) => void
  ? I
  : never;
  
type EventsOverload = UnionToIntersection<Union>

Temporary solution:


type AllowedEvents = ChristmasEvent | EasterEvent

type Events = AllowedEvents['eventName']


type Overloadings = {
  [Prop in Events]: (eventName: Prop, fn: (data: Extract<AllowedEvents, { eventName: Prop }>['data']) => void) => void
}

type Values<T> = T[keyof T]

type Union = Values<Overloadings>


// credits goes to https://stackoverflow.com/a/50375286
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
  k: infer I
) => void
  ? I
  : never;

type EventsOverload = UnionToIntersection<Union> & ((eventName: string, fn: (data: any) => void) => void)


export function Listener(): EventsOverload {

  return (eventName, fn: (data: any) => void) => {
    // someEmitter.on(eventName, fn)
  }
}


const spoiltBrat = {
  on: Listener()
}

spoiltBrat.on('christmas', (data) => {

  data.numberOfPresentsGiven // number
})

spoiltBrat.on('easter', (data) => {
  data.numberOfEggsGiven // number
})

However, it is not perfect yet. You propbably have noticed that I have used any. Nobody likes any. Instead of any, you can provide an intersection of all allowed data arguments:

export function Listener(): EventsOverload {
  return (eventName, fn: (data: ChristmasEvent['data'] & EasterEvent['data']) => void) => {
  }
}

Why intersection ? Because this is the only safe way to handle any eventName. Here you can find more context and explanation.

Whole solution:

interface DefaultEventBody {
  eventName: string
  data: unknown
}

interface ChristmasEvent extends DefaultEventBody {
  eventName: 'christmas',
  data: {
    numberOfPresentsGiven: number
  }
}

interface EasterEvent extends DefaultEventBody {
  eventName: 'easter',
  data: {
    numberOfEggsGiven: number
  }
}

type AllowedEvents = ChristmasEvent | EasterEvent

type Events = AllowedEvents['eventName']

type Overloadings = {
  [Prop in Events]: (eventName: Prop, fn: (data: Extract<AllowedEvents, { eventName: Prop }>['data']) => void) => void
}

type Values<T> = T[keyof T]

type Union = Values<Overloadings>

// credits goes to https://stackoverflow.com/a/50375286
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
  k: infer I
) => void
  ? I
  : never;

type EventsOverload = UnionToIntersection<Union>

export function Listener(): EventsOverload {
  return (eventName, fn: (data: UnionToIntersection<AllowedEvents['data']>) => void) => { }
}


const spoiltBrat = {
  on: Listener()
}

spoiltBrat.on('christmas', (data) => {

  data.numberOfPresentsGiven // number
})

spoiltBrat.on('easter', (data) => {
  data.numberOfEggsGiven // number
})

Playground

Here you have another example, taken from my blog:



const enum Events {
  foo = "foo",
  bar = "bar",
  baz = "baz",
}

/**
 * Single sourse of true
 */
interface EventMap {
  [Events.foo]: { foo: number };
  [Events.bar]: { bar: string };
  [Events.baz]: { baz: string[] };
}



type EmitRecord = {
  [P in keyof EventMap]: (name: P, data: EventMap[P]) => void;
};

type ListenRecord = {
  [P in keyof EventMap]: (
    name: P,
    callback: (arg: EventMap[P]) => void
  ) => void;
};

type Values<T> = T[keyof T];

// credits goes to https://stackoverflow.com/a/50375286
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
  k: infer I
) => void
  ? I
  : never;
  
type MakeOverloadings<T> = UnionToIntersection<Values<T>>;

type Emit = MakeOverloadings<EmitRecord>;
type Listen = MakeOverloadings<ListenRecord>;

const emit: Emit = <T,>(name: string, data: T) => { };

emit(Events.bar, { bar: "1" });
emit(Events.baz, { baz: ["1"] });
emit("unimplemented", { foo: 2 }); // expected error

const listen: Listen = (name: string, callback: (arg: any) => void) => { };

listen(Events.baz, (arg /* {baz: string[] } */) => { });
listen(Events.bar, (arg /* {bar: string } */) => { });

Playground

Please keep in mind that your emitter and listener should have single sourse of true. I mean they shouls use shared event map.


UPDATE

It is a good practice to define your types in global scope. You almost never need to declare types inside function.

/*
 * ListenerFactory.ts
 */

interface DefaultEventBody {
  eventName: string
  data: unknown
}
type Values<T> = T[keyof T]
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
  k: infer I
) => void
  ? I
  : never;

type Overloadings<E extends DefaultEventBody> = {
  [Prop in E['eventName']]: (eventName: Prop, fn: (data: Extract<E, { eventName: Prop }>['data']) => void) => void
}

export function Listener<AllowedEvents extends DefaultEventBody>(): UnionToIntersection<Values<Overloadings<AllowedEvents>>>
export function Listener<AllowedEvents extends DefaultEventBody>() {
  return (eventName: string, fn: (data: UnionToIntersection<AllowedEvents['data']>) => void) => { }
}

/*
 * ConsumingLibrary.ts
 */

interface ChristmasEvent extends DefaultEventBody {
  eventName: 'christmas',
  data: {
    numberOfPresentsGiven: number
  }
}

interface EasterEvent extends DefaultEventBody {
  eventName: 'easter',
  data: {
    numberOfEggsGiven: number
  }
}

const spoiltBrat = {
  on: Listener<ChristmasEvent | EasterEvent>()
}

spoiltBrat.on('christmas', (data) => {

  data.numberOfPresentsGiven // number
})

spoiltBrat.on('easter', (data) => {
  data.numberOfEggsGiven // number
})

Playground

  • 1
    Thanks a lot for this. It's worked as you've said; and I need to devote some time to fully understanding it by going through the links you've sent a few times. My only problem with this is that `Listerner()` needs to be an exported factory function; meaning that I will pass in the `ChristmasEvent|EasterEvent` to it via a generic when calling it from another file. This means I need to block-scope the type setup (from `AllowedEvents` onwards). By doing that, I don't have a return type for `EventsOverload` as it has not been created yet. Any suggestions? +1 for now. – shennan Nov 28 '21 at 09:19
  • Could you please share min reproducible example with imports and comment what you have from third party library and what you need to type. I will try to help – captain-yossarian from Ukraine Nov 28 '21 at 10:56
  • Sure. Playground links are too long for the comments so please see link in updated answer. Essentially my linter wants to know what I'm returning from `Listener(): ?`. But I needed to block-scope `EventsOverload` in order to achieve a factory function, called from another file. – shennan Nov 28 '21 at 11:18
  • @shennan I made an update – captain-yossarian from Ukraine Nov 28 '21 at 12:21