Вопрос или проблема
Как структурировать типы событий 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' }}));
Объяснение подхода:
-
Упрощение интерфейсов событий: Мы определили базовые типы для событий и объединили их в
AllEvents
. -
Использование условных типов: Мы использовали условные типы в интерфейсе
MiddlewareArgs
, чтобы динамически добавлять свойства, специфичные для определенного события. -
Маппинг типов для аргументов промежуточного ПО: Мы создали интерфейс
EventMappings
, который хранит дополнительную информацию о каждом типе события. Это позволяет использовать условные типы для выбора правильных свойств для специфичных событий. - Гибкость и расширяемость: Этот подход легко расширяется. Если вы добавите новое событие, достаточно уточнить его в
AllEvents
и добавить соответствующий маппинг вEventMappings
.
Этот способ структурирования вашего промежуточного ПО и событий не только обеспечивает более строгую типизацию, но также делает код более чистым и поддерживаемым.