Por qué Zod pertenece al borde de Signal
Los listeners del bot son el punto donde eventos externos se convierten en efectos internos. Cambios de roles, actualizaciones de configuración, acciones de moderación y mutaciones de facturación parten de un payload entrante. Usar Zod en este borde te permite rechazar datos mal formados antes de que lleguen a servicios de negocio. No es opcional para bots de producción, porque el payload proviene de sistemas externos que pueden evolucionar de forma independiente, enviar estructuras inesperadas o contener valores inválidos por bugs en el código emisor.
El decorator @OnSignal acepta una opción schema que puede ser cualquier schema Zod. Cuando está presente, SignalProcessor llama a safeParse sobre el payload del envelope antes de invocar el handler. Si la validación falla, el handler nunca se llama y se emite un log de diagnóstico cuando el modo debug está habilitado. Si no hay schema configurado, el payload crudo pasa sin cambios, lo que puede servir para handlers que hacen su propia lógica de parseo.
Valida en el borde, no dentro de los handlers
Adjuntar el schema a @OnSignal garantiza que cada invocación del
handler reciba datos validados y tipados. Esto elimina checks defensivos
dentro del código de negocio y hace confiables las firmas de handlers.
Diseño de schemas y setup del listener
El patrón recomendado es definir schemas en archivos dedicados e importarlos en tus clases listener. Esta separación mantiene los schemas reutilizables entre handlers y permite probarlos de forma independiente. Cada archivo de schema exporta tanto la constante Zod como el tipo TypeScript inferido.
import { z } from 'zod';
export const ConfigUpdateSchema = z.object({
prefix: z.string().min(1).max(5),
language: z.enum(['en', 'pt', 'es']).optional(),
updatedBy: z.string().min(17).max(20),
});
export type ConfigUpdatePayload = z.infer<typeof ConfigUpdateSchema>;Flujo de validación en el processor
Cuando SignalProcessor recibe un envelope validado, comprueba si el handler resuelto tiene un schema adjunto. Si existe, el processor llama a schema.safeParse(envelope.payload) e inspecciona el resultado. Si es exitoso, result.data reemplaza al payload crudo y se pasa al handler. Si falla, el handler se omite por completo y se emite una entrada de diagnóstico con los detalles de issues de Zod.
Este flujo significa que tu handler siempre recibe datos que coinciden con el schema. No necesitas bloques try-catch para acceder al payload, no necesitas aserciones manuales de tipo y no hay riesgo de propiedades undefined causando errores de runtime. El schema actúa al mismo tiempo como contrato de compilación y de ejecución.
Chequeo de presencia de schema
El processor verifica si el handler tiene un schema Zod. Si no hay schema adjunto, envelope.payload se pasa al handler sin cambios. Esto permite que handlers sin necesidad de validación eviten por completo ese overhead.
Ejecución de safeParse
El processor llama a schema.safeParse(envelope.payload), que nunca lanza
excepción. Devuelve una unión discriminada: { success: true, data: T }
con payload parseado y tipado, o { success: false, error: ZodError } con
los issues de validación.
Ruta de éxito
En validación exitosa, result.data reemplaza al payload crudo. Esta versión parseada tiene defaults aplicados, transforms ejecutados y tipos refinados. El handler recibe este valor tipado como primer argumento.
Ruta de falla
En validación fallida, el handler nunca se invoca. Si debug está habilitado, el processor registra los issues de Zod como array JSON, incluyendo path, tipo esperado y valor recibido para cada campo fallido. El envelope se descarta en silencio para evitar que datos corruptos alcancen la lógica de negocio.
Múltiples handlers con distintos schemas
Una sola clase @SignalListener puede contener múltiples métodos @OnSignal con nombres de evento y schemas diferentes. Cada handler se registra de forma independiente en SignalRouter, y cada schema se valida de forma independiente durante el despacho. Esto te permite agrupar handlers de un dominio relacionado en una sola clase mientras mantienes contratos estrictos de payload por evento.
import { SignalListener, OnSignal } from '@spraxium/signal';
import type { SignalEnvelope } from '@spraxium/signal';
import { z } from 'zod';
const PrefixSchema = z.object({
prefix: z.string().min(1).max(5),
});
const LanguageSchema = z.object({
language: z.enum(['en', 'pt', 'es']),
updatedBy: z.string(),
});
@SignalListener()
export class GuildConfigSignal {
@OnSignal('config.prefix.update', { schema: PrefixSchema })
async onPrefixUpdate(
payload: z.infer<typeof PrefixSchema>,
envelope: SignalEnvelope,
): Promise<void> {
// Handle prefix changes
}
@OnSignal('config.language.update', { schema: LanguageSchema })
async onLanguageUpdate(
payload: z.infer<typeof LanguageSchema>,
envelope: SignalEnvelope,
): Promise<void> {
// Handle language changes
}
}Guías para modelar schemas
Los schemas durables siguen un conjunto de principios que minimizan cambios breaking entre despliegues de productor y consumidor. Como el emisor y el bot pueden desplegarse de manera independiente, la evolución del schema debe manejarse con cuidado.
| Principio | Práctica | Razón |
|---|---|---|
| Cambios aditivos | Agrega campos nuevos como | Permite un rollout gradual entre despliegues del emisor y del bot. |
| Payloads pequeños | Incluye solo campos que el bot realmente necesita para decidir. | Reduce acoplamiento y facilita mantener schemas en el tiempo. |
| Límites estrictos | Usa | Detecta datos inválidos temprano y evita errores lógicos downstream. |
| Nombres de evento versionados | Usa | Preserva compatibilidad hacia atrás durante periodos de transición. |
| Enums explícitos | Usa | Evita typos y valores no documentados llegando a los handlers. |
Patrones avanzados de schema
Para eventos de dominio complejos, Zod soporta patrones de composición potentes que mantienen schemas DRY y expresivos. Las discriminated unions permiten validar distintas formas de payload según un campo discriminador compartido. Las intersecciones permiten combinar campos base con extensiones específicas del evento.
import { z } from 'zod';
const ActivatedSchema = z.object({
action: z.literal('activated'),
planId: z.string(),
userId: z.string(),
expiresAt: z.number(),
});
const CancelledSchema = z.object({
action: z.literal('cancelled'),
userId: z.string(),
reason: z.string().optional(),
});
export const BillingEventSchema = z.discriminatedUnion('action', [
ActivatedSchema,
CancelledSchema,
]);
export type BillingEventPayload = z.infer<typeof BillingEventSchema>;Responsabilidades de reintento
El paquete @spraxium/signal del lado del bot no incluye cola interna de reintentos ni worker. Si una validación Zod falla, el envelope se descarta y no se intenta reintento automático. Esto es intencional porque el bot debe mantenerse como consumidor determinista de eventos. La lógica de reintentos pertenece al lado emisor, donde @spraxium/signal-client provee persistencia de fallback con backoff exponencial.
Esta separación mantiene cada lado enfocado en su responsabilidad. El bot valida y despacha envelopes confiables. El emisor maneja confiabilidad de entrega, programación de reintentos y monitoreo de dead-letter. Si necesitas reprocesar un envelope fallido, el emisor debe corregir la forma del payload y reenviarlo en lugar de que el bot tolere datos inválidos.
La propiedad de reintentos está del lado productor
Bot-side @spraxium/signal no reintenta payloads fallidos por
diseño. Si necesitas reintentos de entrega, configúralos del lado emisor con
ajustes de fallback de @spraxium/signal-client usando store de
archivo o Redis.
Testing con createSignalPayload
El paquete @spraxium/signal exporta un helper createSignalPayload que construye envelopes sin firmar para testing. Este helper genera un UUID nonce y un timestamp actual, devolviendo un UnsignedEnvelope utilizable en pruebas unitarias para verificar comportamiento del handler sin levantar todo el pipeline de Signal.
import { createSignalPayload } from '@spraxium/signal';
const testEnvelope = createSignalPayload(
'config.update',
'123456789012345678',
{
prefix: '!',
language: 'en',
},
);
// testEnvelope has: v, event, guildId, nonce, sentAt, payload
// No signature field (unsigned, for testing only)