Spraxium logoSpraxium

Validación con Zod en listeners del bot

Valida payloads de Signal con schemas Zod antes de que se ejecuten tus handlers de dominio. Aprende patrones de diseño de schemas, mecánica del flujo de validación, extracción de payload tipado y manejo de errores para envelopes rechazados.

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.

src/modules/signal/listeners/guild-config.signal.ts
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.

PrincipioPrácticaRazón
Cambios aditivos

Agrega campos nuevos como .optional() antes de hacerlos obligatorios.

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 .min(), .max(), .enum() y .regex() para strings y números.

Detecta datos inválidos temprano y evita errores lógicos downstream.

Nombres de evento versionados

Usa config.update.v2 para cambios breaking de payload en lugar de modificar el schema original.

Preserva compatibilidad hacia atrás durante periodos de transición.

Enums explícitos

Usa z.enum() para conjuntos de valores fijos en lugar de strings libres.

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.

test/guild-config.signal.test.ts
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)