问题
This is a continuation of this exploration that figures out a reusable mechanism that lets us assign the incoming event(message) to the appropriate event handler and be completely type-reliant along the way. Here's what we wanna make reusable:
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]; // Notice the seemingly unnecessary assertion. This is the reason we are making this function generic.
return handler(e);
};
I want us to ideally end up here:
const handleEvent = eventAssigner<CrmEventsMap>(handlers, 'kind');
It all starts with a map that associates the events discriminator to the event body:
interface CrmEventsMap {
event1: { attr1: string, attr2: number }
event2: { attr3: boolean, attr4: string }
}
From which, we can create the complete Event type (one that includes the discriminator):
type CrmEvent<K extends keyof CrmEventsMap> = { kind: K } & EventsMap[K]
We now have everything we need to declare the handlers map:
const handlers: { [K in keyof CrmEventsMap]: (e: CrmEvent<K>) => Promise<void> } = {
event1: ({attr1, attr2}) => Promise.resolve(),
event2: ({attr3, attr4}) => Promise.resolve(),
};
Which brings us back to handleEvent. The type assertion in the body seems like a reason enough to try and make the function generic.
Here's an attempt:
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]
It's quite convoluted, even in it's usage. But then, just by fixing-in the events discriminator field to 'kind', we dramatically 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 especially interesting in this one is that for some reason I'm not able to explain, we don't need the type assertion.
Still, for any of these two functions to work, they need to be provided the concrete type arguments, which means wrapping them in another function:
const handleEvent =
<E extends CrmEventKind>
(e: CrmEvent<E>): ReturnType<(typeof handlers)[E]> =>
eventAssigner<CrmEventMap, E>(handlers)(e);
So in short, how much closer to the ideal implementation do you think we can get?
Here's a playground.
回答1:
After hitting myself in the head a few times to understand what's going on here, I've got something.
First I'd suggest loosening your type for handlers a bit so as not to require that the handler arguments feature the "kind" discriminant, like this:
interface CrmEventMap {
event1: { attr1: string; attr2: number };
event2: { attr3: boolean; attr4: string };
}
const handlers: {
[K in keyof CrmEventMap]: (e: CrmEventMap[K]) => Promise<void>
} = {
event1: ({ attr1, attr2 }) => Promise.resolve(),
event2: ({ attr3, attr4 }) => Promise.resolve()
};
So you don't need CrmEvent<K> at all here. Your eventual handleEvent implementation will need to use a discriminant to tell how to dispatch events, but the handlers above doesn't care: each function will only operate on an event that has already been appropriately dispatched. You can keep the above stuff the same as you had it if you want, but it seems unnecessary to me.
Now for the implementation of eventAssigner:
const eventAssigner = <
M extends Record<keyof M, (e: any) => any>,
D extends keyof any
>(
handlers: M,
discriminant: D
) => <K extends keyof M>(
event: Record<D, K> & (Parameters<M[K]>[0])
): ReturnType<M[K]> => handlers[event[discriminant]](event);
So, eventAssigner is a curried generic function. It is generic in M, the type of handlers (which you have as the variable handlers) which must be an object holding one-argument function properties, and D, the type of discriminant (which you have as the string "kind") which must be a valid key type. It then returns another function which is generic in K, intended to be one of the keys of M. Its event parameter is of type Record<D, K> & (Parameters<M[K]>[0]) which basically means it must be the same type argument as the K-keyed property of M, as well as an object with a discriminant key D and value K. This is the analog of your CrmEvent<K> type.
And it returns ReturnType<M[K]>. This implementation doesn't need a type assertion only because the constraint on M has each handler function extend (e: any)=>any. So when the compiler examines handlers[event[discriminant]] it sees a function that must be assignable to (e: any)=>any, and you can basically call it on any argument and return any type. So it would happily let you return handlers[event[discriminant]]("whoopsie") + 15. So you need to be careful here. You could dispense with any and use something like (e: never)=>unknown which would be safer but then you'd have to use a type assertion. It's up to you.
Anyway here's how you use it:
const handleEvent = eventAssigner(handlers, "kind");
Note that you are just using generic type inference and don't have to specify anything like <CrmEventsMap> in there. In my opinion using type inference is more
"ideal" than manually specifying things. If you want to specify something here it would have to be eventAssigner<typeof handlers, "kind">(handlers, "kind"), which is silly.
And making sure that it behaves as you expect:
const event1Response = handleEvent({ kind: "event1", attr1: "a", attr2: 3 }); // Promise<void>
const event2Response = handleEvent({ kind: "event2", attr3: true, attr4: "b" }); // Promise<void>
Looks good. Okay, hope that helps. Good luck!
Link to code
来源:https://stackoverflow.com/questions/56762896/make-a-nicely-typed-generic-event-to-handler-assigner-function