¿Cómo estructurar las formas de eventos de TypeScript para permitir argumentos de middleware específicos de eventos?
Estoy trabajando en un marco de aplicación impulsado por eventos que aprovecha un patrón de middleware para procesar eventos enviados desde mi servicio a aplicaciones de terceros. Mi servicio modela muchos tipos diferentes de eventos para los cuales las aplicaciones de terceros pueden desear registrarse y recibir.
A medida que los payloads de eventos son recibidos por el framework, estos son 'aumentados' con adiciones específicas del middleware como una función next() y un context definible por el usuario, cosas relativamente estándar de middleware. Sin embargo, algunos eventos tienen aumentos específicos adicionales disponibles solo en ciertos eventos. Por ejemplo, los eventos de mensaje tienen una propiedad message, los eventos foo pueden tener una propiedad foo, y así sucesivamente, aunque el tipo/nombre del evento y los aumentos específicos del evento pueden no coincidir perfectamente uno a uno y/o pueden tener múltiples aumentos.
La forma en que estas ampliaciones específicas de eventos están tipadas actualmente en el framework de la aplicación es problemática y causa errores con frecuencia; como resultado, la base de código usa muchas aserciones de tipo (as), lo cual probablemente sea una mala señal. Admito que no soy un experto en TypeScript y que el código del framework es anterior a mi participación; simplemente pensé 'supongo que así se construyen los proyectos en TS'. Después de un par de años y de aprender más sobre TypeScript, ¡ahora creo que debe haber una mejor manera!
Aquí hay un playground de TypeScript para el ejemplo, que también copié a continuación. El método wrapMiddleware genera un error con:
Argument of type 'MiddlewareArgs<"message">' is not assignable to parameter of type 'MiddlewareArgs<string>'.
Types of property 'message' are incompatible.
Type 'MsgEvent' is not assignable to type 'undefined'.
import { expectAssignable } from 'tsd';
// A couple of events, and a union of all events (there are more in actuality)
interface MsgEvent {
type: 'message';
text: string;
channel: string;
user: string;
}
interface JoinEvent {
type: 'join';
channel: string;
user: string;
}
type AllEvents = MsgEvent | JoinEvent;
// Utility types to 'extract' event payloads out based on the `type` property
type KnownEventFromType<T extends string> = Extract<AllEvents, { type: T }>;
type EventFromType<T extends string> = KnownEventFromType<T> extends never ? { type: T } : KnownEventFromType<T>;
// A contrived example of arguments passed to middleware
interface MiddlewareArgs<EventType extends string = string> {
event: EventFromType<EventType>;
message: EventType extends 'message' ? this['event'] : undefined; // <-- problematic; there must be a better way, right?
}
// Augmenting events with additional middleware things
type AllMiddlewareArgs = {
next: () => Promise<void>;
}
function wrapMiddleware<Args extends MiddlewareArgs>(
args: Args,
): Args & AllMiddlewareArgs {
return {
...args,
next: async () => {},
}
}
// And now for the actual example:
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, // <-- bonus points if we can get rid of having to set an undefined message!
}
// Some test cases
expectAssignable<AllMiddlewareArgs>(wrapMiddleware(messageEvtArgs));
expectAssignable<MiddlewareArgs<'message'>>(wrapMiddleware(messageEvtArgs));
expectAssignable<AllMiddlewareArgs>(wrapMiddleware(joinEvtArgs));
expectAssignable<MiddlewareArgs<'join'>>(wrapMiddleware(joinEvtArgs));
// Wrapping random untyped events should fallback
expectAssignable<AllMiddlewareArgs>(wrapMiddleware({ event: { type: 'garbage' }}));
expectAssignable<MiddlewareArgs<'garbage'>>(wrapMiddleware({ event: { type: 'garbage' }}));
Entiendo por qué obtengo el error que obtengo: el método wrapMiddleware acepta el tipo más amplio de MiddlewareArgs, no específico de message, que establece el parámetro genérico como string, por lo que la propiedad message de la interfaz podría ser un objeto de mensaje o undefined según el tipo condicional para la propiedad message. Mi pregunta es: ¿cuál es una mejor manera de estructurar este enfoque para que escale a más eventos, con diferentes formas de argumentos de middleware específicos de cada evento?
Respuesta #1
Para que todas tus pruebas pasen, con mucho la forma más fácil es permitir que wrapMiddleware() acepte cualquier objeto como entrada:
function wrapMiddleware<A extends object>(a: A): A & AllMiddlewareArgs {
return {
...a,
next: async () => { },
}
}
// all okay
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' } }));
Eso es porque, en un nivel básico, todo lo que wrapMiddleware hace es expandir la entrada en un nuevo objeto con un método next agregado del tipo write. Si solo te importa que tales pruebas pasen, entonces así es como deberías hacerlo. Es tan simple.
La única razón por la que harías otra cosa es si de alguna manera quieres rechazar entradas que no cumplan con alguna restricción fuera de banda:
function wrapMiddleware<A extends { event: { type: string } }>(
a: A & MiddlewareArgs<A["event"]["type"]>
): A & AllMiddlewareArgs {
return {
...a,
next: async () => { },
}
}
Ahora solo aceptaremos a cuyo tipo A sea algo con una propiedad type de valor string en su propiedad event, y además, solo algo que sea asignable a MiddlewareArgs<A["event"]["type"]>. Es una validación para asegurarse de que los tipos conocidos tengan las propiedades que te importan. En su mayoría, puedes dejar tus definiciones tal como están, pero lo importante que permite que message pueda faltar y no sea undefined a menos que type sea "message" es cambiar tu MiddlewareArgs a una versión que coloque el tipo condicional a un nivel superior, de manera que o se requiera { message: MsgEvent } o no:
type MiddlewareArgs<K extends string = string> = {
event: EventFromType<K>;
} & (K extends "message" ? { message: MsgEvent } : unknown)
Tenga en cuenta que el tipo unknown es el elemento identidad para las intersecciones, por lo que {event: EventFromType<K>} & unknown es simplemente {event: EventFromType<K>}.
Tus pruebas existentes aún pasan, pero ahora algunas cosas pueden fallar, como:
wrapMiddleware({ event: { type: "message", channel: "", text: "", user: "" } }); // error!
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Property 'message' is missing
wrapMiddleware({ event: { type: "join", user: "" } }); // error!
// ~~~~~
// Property 'channel' is missing
Enlace de Playground al código
