Here we are continuing from the previous exploration that aims to develop a reusable mechanism for assigning incoming events (messages) to appropriate event handlers while maintaining complete type reliance. Our goal is to create a reusable function for handling events.
const handleEvent =
<EventKind extends keyof EventsMap>
(e: Event<EventKind>): Promise<void> => {
const kind: EventKind = e.kind;
const handler = <(e: CrmEvent<EventKind>) => Promise<void>>handlers[kind]; // The unnecessary assertion here prompts us to make this function generic.
return handler(e);
};
Our desired end result is:
const handleEvent = eventAssigner<CrmEventsMap>(handlers, 'kind');
It all starts with a map connecting event discriminators to event details:
interface CrmEventsMap {
event1: { attr1: string, attr2: number }
event2: { attr3: boolean, attr4: string }
}
This allows us to define the complete Event type, including discriminator:
type CrmEvent<K extends keyof CrmEventsMap> = { kind: K } & EventsMap[K]
We now have everything necessary to create the handlers map:
const handlers: { [K in keyof CrmEventsMap]: (e: CrmEvent<K>) => Promise<void> } = {
event1: ({attr1, attr2}) => Promise.resolve(),
event2: ({attr3, attr4}) => Promise.resolve(),
};
This leads us back to handleEvent
. The need for a type assertion within the function body is a strong motivator to make it generic.
Let's try to simplify with this approach:
const eventAssigner =
<EventMap extends {},
EventKind extends keyof EventMap,
KindField extends string>
(
handlers: { [k in keyof EventMap]: (e: EventType<EventMap, k, KindField>) => any },
kindField: KindField
) =>
(e: EventType<EventMap, EventKind, KindField>):
ReturnType<(typeof handlers)[EventKind]> => {
const kind = e[kindField];
const handler = <(e: EventType<EventMap, EventKind, KindField>) => ReturnType<(typeof handlers)[EventKind]>>handlers[kind];
return handler(e);
};
type EventType<EventMap extends {}, Kind extends keyof EventMap, KindField extends string> =
{ [k in KindField]: Kind } & EventMap[Kind]
Although complex, the usage of this function is still manageable. By fixing the event discriminator field to 'kind'
, we greatly simplify things:
const eventAssigner =
<EventMap extends {},
EventKind extends keyof EventMap>
(handlers: { [k in keyof EventMap]: (e: EventType<EventMap, k>) => any }) =>
(e: EventType<EventMap, EventKind>):
ReturnType<(typeof handlers)[EventKind]> =>
handlers[e.kind](e);
type EventType<EventMap extends {}, Kind extends keyof EventMap> = { kind: Kind } & EventMap[Kind]
What's intriguing is that this version does not require a type assertion, unlike the previous one.
To use either of these functions, concrete type arguments must be provided by wrapping them in another function:
const handleEvent =
<E extends CrmEventKind>
(e: CrmEvent<E>): ReturnType<(typeof handlers)[E]> =>
eventAssigner<CrmEventMap, E>(handlers)(e);
In conclusion, how close do you think we can get to achieving the ideal implementation?