Сделайте хорошо типизированную универсальную функцию присваивания события обработчику - PullRequest
1 голос
/ 26 июня 2019

Это продолжение этого исследования , которое выясняет механизм многократного использования, который позволяет нам назначать входящее событие (сообщение) соответствующему обработчику событий и полностью зависеть от типа на этом пути. Вот что мы хотим сделать многоразовым:

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);
};

Я хочу, чтобы мы в идеале оказались здесь:

const handleEvent = eventAssigner<CrmEventsMap>(handlers, 'kind');

Все начинается с карты, которая связывает дискриминатор событий с телом события:

interface CrmEventsMap {
  event1: { attr1: string,  attr2: number }
  event2: { attr3: boolean, attr4: string }
}

Из которого мы можем создать полный тип события (тот, который включает дискриминатор):

type CrmEvent<K extends keyof CrmEventsMap> = { kind: K } & EventsMap[K]

Теперь у нас есть все необходимое для объявления карты обработчиков:

const handlers: { [K in keyof CrmEventsMap]: (e: CrmEvent<K>) => Promise<void> } = {
  event1: ({attr1, attr2}) => Promise.resolve(),
  event2: ({attr3, attr4}) => Promise.resolve(),
};

Что возвращает нас к handleEvent. Утверждение типа в теле кажется достаточной причиной, чтобы попытаться сделать функцию универсальной.

Вот попытка:

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]

Это довольно запутанно, даже в использовании. Но затем, просто зафиксировав поле дискриминатора событий в 'kind', мы значительно упростили вещи:

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]

Что особенно интересно в этом, так это то, что по какой-то причине я не могу объяснить, нам не нужно утверждение типа.

Тем не менее, чтобы любая из этих двух функций работала, им необходимо предоставить конкретные аргументы типа, что означает их оборачивание в другую функцию:

const handleEvent = 
  <E extends CrmEventKind>
  (e: CrmEvent<E>): ReturnType<(typeof handlers)[E]> => 
    eventAssigner<CrmEventMap, E>(handlers)(e);

Короче говоря, насколько мы можем приблизиться к идеальной реализации?

Вот детская площадка.

1 Ответ

1 голос
/ 26 июня 2019

После нескольких ударов в голову, чтобы понять, что здесь происходит, у меня что-то есть.

Сначала я бы предложил немного ослабить ваш тип для handlers, чтобы не требовалось , чтобы аргументы обработчика содержали дискриминант "kind", например:

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()
};

Так что вам здесь вообще не нужно CrmEvent<K>. Ваша возможная реализация handleEvent должна будет использовать дискриминант, чтобы сообщить, как отправлять события, но вышеприведенное handlers не имеет значения: каждая функция будет работать только с событием, которое уже было отправлено соответствующим образом. Вы можете оставить все то же самое, что и у вас, если хотите, но мне это не нужно.

Теперь для реализации 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);

Итак, eventAssigner является универсальной карри-функцией. Это универсальный тип в M, тип handlers (который у вас есть в качестве переменной handlers), который должен быть объектом, содержащим свойства функции с одним аргументом, и D, тип discriminant (который у вас есть строка "kind"), которая должна иметь правильный тип ключа. Затем он возвращает другую функцию, которая является общей в K, предназначенную для использования в качестве одной из клавиш M. Его параметр event имеет тип Record<D, K> & (Parameters<M[K]>[0]), что в основном означает, что это должен быть тот же аргумент типа, что и у K -ключевого свойства M, а также объект с ключом дискриминанта D и значением K. Это аналог вашего CrmEvent<K> типа.

И он возвращает ReturnType<M[K]>. Эта реализация не нуждается в утверждении типа только потому, что ограничение на M имеет каждую функцию-обработчик extension (e: any)=>any. Поэтому, когда компилятор проверяет handlers[event[discriminant]], он видит функцию, которую необходимо присвоить (e: any)=>any, и вы можете в основном вызывать ее для любого аргумента и возвращать любой тип. Так что он с радостью позволил бы вам вернуться handlers[event[discriminant]]("whoopsie") + 15. Так что вам нужно быть осторожным здесь. Вы можете обойтись без any и использовать что-то вроде (e: never)=>unknown, что будет безопаснее, но тогда вам придется использовать утверждение типа. Вам решать.

В любом случае, вот как вы его используете:

const handleEvent = eventAssigner(handlers, "kind");

Обратите внимание, что вы просто используете вывод общего типа и вам не нужно указывать там что-то вроде <CrmEventsMap>. По моему мнению, использование вывода типа является более «идеальный», чем ручное указание вещей. Если вы хотите указать что-то здесь, это должно быть eventAssigner<typeof handlers, "kind">(handlers, "kind"), что глупо.

И убедившись, что он ведет себя так, как вы ожидаете:

const event1Response = handleEvent({ kind: "event1", attr1: "a", attr2: 3 }); // Promise<void>
const event2Response = handleEvent({ kind: "event2", attr3: true, attr4: "b" }); // Promise<void>

Хорошо выглядит. Хорошо, надеюсь, это поможет. Удачи!

Ссылка на код

...