Как структурировать типы событий TypeScript для поддержки аргументов промежуточного ПО, специфичных для события?

Вопрос или проблема

Как структурировать типы событий TypeScript для поддержки аргументов промежуточного ПО, специфичных для события?

Я работаю над фреймворком приложений, реагирующим на события, который использует паттерн промежуточного ПО для обработки событий, отправляемых от моего сервиса к сторонним приложениям. Мой сервис моделирует многие различные виды событий, на которые сторонние приложения могут зарегистрироваться и получать.

Когда полезные данные события поступают во фреймворк, они дополняются специфическими для промежуточного ПО элементами, такими как функция next() и пользовательски определяемый context — это довольно стандартные вещи для промежуточного ПО. Однако некоторые события имеют дополнительные специфические дополнения, доступные только для конкретных событий. Например, события сообщений имеют свойство message, события foo могут иметь свойство foo и так далее — хотя тип/имя события и специфичные дополнения могут не совпадать один к одному и/или у них могут быть несколько дополнений.

Текущий способ типизации этих специфических для события дополнений в фреймворке приложения проблематичен и часто вызывает ошибки; в результате кодовая база часто использует утверждения типов (as) — вероятно, это плохой признак. Признаю, я не эксперт по TypeScript, а код фреймворка был написан до моего участия; я просто подумал: «Наверное, именно так строятся проекты на TS». После пары лет и изучения больше о TypeScript, я сейчас думаю, что должен быть лучший способ!

Вот пример TypeScript, который я также скопировал ниже. Метод wrapMiddleware вызывает ошибку:

Аргумент типа 'MiddlewareArgs<"message">' не может быть присвоен параметру типа 'MiddlewareArgs<string>'.
  Типы свойства 'message' несовместимы.
    Тип 'MsgEvent' не может быть присвоен типу 'undefined'.
import { expectAssignable } from 'tsd';

// Пара событий и объединение всех событий (на самом деле их больше)
interface MsgEvent {
  type: 'message';
  text: string;
  channel: string;
  user: string;
}
interface JoinEvent {
  type: 'join';
  channel: string;
  user: string;
}
type AllEvents = MsgEvent | JoinEvent;

// Утилитарные типы для 'извлечения' полезных данных событий на основе свойства `type`
type KnownEventFromType<T extends string> = Extract<AllEvents, { type: T }>;
type EventFromType<T extends string> = KnownEventFromType<T> extends never ? { type: T } : KnownEventFromType<T>;

// Придуманный пример аргументов, передаваемых в промежуточное ПО
interface MiddlewareArgs<EventType extends string = string> {
  event: EventFromType<EventType>;
  message: EventType extends 'message' ? this['event'] : undefined; // <-- проблематично; должен быть лучший способ, не так ли?
}

// Дополнение событий дополнительными элементами промежуточного ПО
type AllMiddlewareArgs = {
  next: () => Promise<void>;
}
function wrapMiddleware<Args extends MiddlewareArgs>(
  args: Args,
): Args & AllMiddlewareArgs {
  return {
    ...args,
    next: async () => {},
  }
}

// И теперь сам пример:
const messageEvt: MsgEvent = {
  type: 'message',
  channel: 'random',
  user: 'me',
  text: 'hello world',
}
const messageEvtArgs: MiddlewareArgs<'message'> = {
  event: messageEvt,
  message: messageEvt,
}
const joinEvt: JoinEvent = {
  type: 'join',
  channel: 'random',
  user: 'me'
}
const joinEvtArgs: MiddlewareArgs<'join'> = {
  event: joinEvt,
  message: undefined, // <-- дополнительные баллы, если мы сможем избавиться от необходимости устанавливать undefined для сообщения!
}

// Некоторые тестовые случаи
expectAssignable<AllMiddlewareArgs>(wrapMiddleware(messageEvtArgs));
expectAssignable<MiddlewareArgs<'message'>>(wrapMiddleware(messageEvtArgs));
expectAssignable<AllMiddlewareArgs>(wrapMiddleware(joinEvtArgs));
expectAssignable<MiddlewareArgs<'join'>>(wrapMiddleware(joinEvtArgs));
// Оборачивание случайных нетипизированных событий должно возвращать значение по умолчанию
expectAssignable<AllMiddlewareArgs>(wrapMiddleware({ event: { type: 'garbage' }}));
expectAssignable<MiddlewareArgs<'garbage'>>(wrapMiddleware({ event: { type: 'garbage' }}));

Я понимаю почему я получаю свою ошибку: метод wrapMiddleware принимает более широкий тип MiddlewareArgs, не специфичный для message, что устанавливает параметр типа как string, поэтому свойство message интерфейса может быть либо объектом сообщения, либо undefined в соответствии с условным типом для свойства message. Мой вопрос: как лучше структурировать этот подход, чтобы он масштабировался на большее количество событий с различными формами аргументов промежуточного ПО, специфичных для события?

Ответ или решение

Чтобы структурировать формы событий в TypeScript таким образом, чтобы позволить передавать аргументы, специфичные для промежуточного ПО (middleware), важно создать гибкую и расширяемую архитектуру для обработки событий. Я предлагаю использовать условные типы и маппинг типов для достижения желаемого результата, чтобы избежать проблем с приведением типов и указанием undefined. Мы будем использовать дженерики и улучшенные конструкции типов, чтобы обеспечить правильную типизацию для различных событий и их специфического промежуточного ПО.

Вот переработанный пример, который демонстрирует, как можно достичь этой цели:

import { expectAssignable } from 'tsd';

// Определяем интерфейсы событий
interface MsgEvent {
  type: 'message';
  text: string;
  channel: string;
  user: string;
}

interface JoinEvent {
  type: 'join';
  channel: string;
  user: string;
}

// Объединенный тип всех событий
type AllEvents = MsgEvent | JoinEvent;

// Утилиты для извлечения событий по типу
type KnownEventFromType<T extends string> = Extract<AllEvents, { type: T }>;
type EventFromType<T extends string> = KnownEventFromType<T> extends never ? { type: T } : KnownEventFromType<T>;

// Интерфейс аргументов промежуточного ПО с использованием условных типов
interface MiddlewareArgs<EventType extends keyof EventMappings = keyof AllEventMappings> {
  event: EventFromType<EventType>;
} & EventMappings[EventType];

// Определяем маппинг для спецификации остальных аргументов промежуточного ПО по типам событий
interface EventMappings {
  message: {
    message: MsgEvent;
  };
  join: {
    message?: undefined; // Тут мы указываем, что для события 'join' поле message может отсутствовать
  };
  // Здесь вы можете добавить дополнительные события с их специфичными расширениями
}

// Функция для обертки промежуточного ПО
type AllMiddlewareArgs = {
  next: () => Promise<void>;
}

function wrapMiddleware<Args extends MiddlewareArgs>(
  args: Args,
): Args & AllMiddlewareArgs {
  return {
    ...args,
    next: async () => {},
  };
}

// Пример использования
const messageEvt: MsgEvent = {
  type: 'message',
  channel: 'random',
  user: 'me',
  text: 'hello world',
};

const messageEvtArgs: MiddlewareArgs<'message'> = {
  event: messageEvt,
  message: messageEvt,
};

const joinEvt: JoinEvent = {
  type: 'join',
  channel: 'random',
  user: 'me'
};

const joinEvtArgs: MiddlewareArgs<'join'> = {
  event: joinEvt,
  message: undefined, // Теперь это допустимо, так как оно на самом деле не требуется
};

// Тесты
expectAssignable<AllMiddlewareArgs>(wrapMiddleware(messageEvtArgs));
expectAssignable<MiddlewareArgs<'message'>>(wrapMiddleware(messageEvtArgs));
expectAssignable<AllMiddlewareArgs>(wrapMiddleware(joinEvtArgs));
expectAssignable<MiddlewareArgs<'join'>>(wrapMiddleware(joinEvtArgs));
expectAssignable<AllMiddlewareArgs>(wrapMiddleware({ event: { type: 'garbage' }}));
expectAssignable<MiddlewareArgs<'garbage'>>(wrapMiddleware({ event: { type: 'garbage' }}));

Объяснение подхода:

  1. Упрощение интерфейсов событий: Мы определили базовые типы для событий и объединили их в AllEvents.

  2. Использование условных типов: Мы использовали условные типы в интерфейсе MiddlewareArgs, чтобы динамически добавлять свойства, специфичные для определенного события.

  3. Маппинг типов для аргументов промежуточного ПО: Мы создали интерфейс EventMappings, который хранит дополнительную информацию о каждом типе события. Это позволяет использовать условные типы для выбора правильных свойств для специфичных событий.

  4. Гибкость и расширяемость: Этот подход легко расширяется. Если вы добавите новое событие, достаточно уточнить его в AllEvents и добавить соответствующий маппинг в EventMappings.

Этот способ структурирования вашего промежуточного ПО и событий не только обеспечивает более строгую типизацию, но также делает код более чистым и поддерживаемым.

Оцените материал
Добавить комментарий

Капча загружается...