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