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