Reputation: 11666
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?
As per the request from Captain Yossarian, here is a Playground Link with the almost finished script.
Upvotes: 1
Views: 167
Reputation: 33091
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
})
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 } */) => { });
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
})
Upvotes: 1