Spraxium logoSpraxium

Validação com Zod em listeners do bot

Valide payloads do Signal com schemas Zod antes que seus handlers de domínio sejam executados. Aprenda padrões de modelagem de schema, o funcionamento do fluxo de validação, extração de payload tipado e tratamento de erros para envelopes rejeitados.

Por que o Zod pertence à fronteira do Signal

Listeners do bot são o ponto em que eventos externos viram efeitos colaterais internos. Mudanças de cargo, atualizações de configuração, ações de moderação e mutações de billing começam com um payload recebido. Usar Zod nessa fronteira permite rejeitar dados malformados antes que eles alcancem os serviços de negócio. Isso não é opcional em bots de produção, porque o payload vem de sistemas externos que podem evoluir de forma independente, enviar formatos inesperados ou conter valores inválidos por bugs no código emissor.

O decorator @OnSignal aceita uma opção schema que recebe qualquer schema Zod. Quando ela está presente, o SignalProcessor chama safeParse no payload do envelope antes de invocar o handler. Se a validação falhar, o handler nunca é chamado e um log de diagnóstico é emitido quando o modo debug está habilitado. Se nenhum schema for configurado, o payload bruto segue sem alterações, o que é útil em handlers que fazem seu próprio parsing.

Valide na fronteira, não dentro dos handlers

Associar o schema a @OnSignal garante que toda invocação do handler receba dados validados e tipados. Isso elimina checagens defensivas dentro do código de negócio e torna a assinatura do handler confiável.

Modelagem de schema e configuração do listener

O padrão recomendado é definir schemas em arquivos dedicados e importá-los nas suas classes de listener. Essa separação mantém os schemas reutilizáveis entre handlers e fáceis de testar isoladamente. Cada arquivo de schema exporta tanto a constante do schema Zod quanto o 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>;

Fluxo de validação no processor

Quando o SignalProcessor recebe um envelope já validado, ele verifica se o handler resolvido possui um schema associado. Se possuir, o processor chama schema.safeParse(envelope.payload) e inspeciona o resultado. Em caso de sucesso, result.data substitui o payload bruto e é passado ao handler. Em caso de falha, o handler é totalmente ignorado e uma entrada de log de diagnóstico é emitida com os detalhes do erro do Zod.

Isso significa que seu handler recebe, com garantia, dados compatíveis com o schema. Não há necessidade de blocos try-catch para acessar o payload, nem de type assertions manuais, nem do risco de propriedades indefinidas causarem erros em runtime. O schema funciona, ao mesmo tempo, como contrato de compilação e de execução.

Checagem de presença do schema

O processor verifica se o handler tem um schema Zod associado. Se não houver schema, o envelope.payload bruto é passado ao handler sem alterações. Isso permite que handlers que não precisam de validação pulem totalmente esse custo.

Execução do safe parse

O processor chama schema.safeParse(envelope.payload), que nunca lança exceção. O retorno é uma união discriminada: ou { success: true, data: T } com o payload parseado e tipado, ou { success: false, error: ZodError } com os problemas de validação.

Caminho de sucesso

Quando a validação passa, result.data substitui o payload bruto. Essa versão parseada já traz defaults aplicados, transforms executados e tipos refinados. O handler recebe esse valor tipado como primeiro argumento.

Caminho de falha

Quando a validação falha, o handler nunca é invocado. Se o modo debug estiver habilitado, o processor registra os problemas do Zod como um array JSON, incluindo path, tipo esperado e valor recebido em cada campo que falhou. O envelope é descartado em silêncio para impedir que dados corrompidos cheguem à lógica de negócio.

Múltiplos handlers com schemas diferentes

Uma única classe @SignalListener pode conter vários métodos @OnSignal com nomes de evento e schemas diferentes. Cada handler é registrado de forma independente no SignalRouter, e cada schema é validado de forma independente durante o despacho. Isso permite agrupar handlers relacionados de um mesmo domínio em uma única classe, mantendo contratos rígidos 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
  }
}

Diretrizes para modelagem de schema

Schemas duráveis seguem um conjunto de princípios que minimizam quebras entre implantações de produtor e consumidor. Como o emissor e o bot podem ser implantados de forma independente, a evolução do schema precisa ser tratada com cuidado.

PrincípioPráticaMotivo
Mudanças aditivas

Adicione campos novos como .optional() antes de torná-los obrigatórios.

Permite rollout gradual entre implantações do emissor e do bot.

Payloads enxutos

Inclua apenas os campos de que o bot realmente precisa para decidir o que fazer.

Reduz acoplamento e torna os schemas mais fáceis de manter ao longo do tempo.

Limites rígidos

Use .min(), .max(), .enum() e .regex() para strings e números.

Antecipam dados inválidos e evitam erros de lógica mais à frente.

Nomes de evento versionados

Use config.update.v2 para mudanças incompatíveis no payload em vez de alterar o schema original.

Preserva compatibilidade retroativa durante períodos de transição.

Enums explícitos

Use z.enum() para conjuntos fixos de valores em vez de strings livres.

Evita erros de digitação e valores não documentados chegando aos handlers.

Padrões avançados de schema

Para eventos de domínio mais complexos, o Zod oferece padrões poderosos de composição que mantêm os schemas enxutos e expressivos. Uniões discriminadas permitem validar formatos diferentes de payload com base em um campo discriminador compartilhado. Interseções permitem combinar campos-base com extensões específicas de cada 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>;

Responsabilidade por retry

O pacote @spraxium/signal no lado do bot não inclui fila nem worker interno de retry. Se uma validação com Zod falhar, o envelope é descartado e nenhuma nova tentativa automática é feita. Isso é intencional, porque o bot deve continuar sendo um consumidor determinístico de eventos. A lógica de retry pertence ao lado do emissor, onde o pacote @spraxium/signal-client fornece persistência de fallback com backoff exponencial.

Essa separação mantém cada lado concentrado na própria responsabilidade. O bot valida e despacha envelopes confiáveis. O emissor cuida da confiabilidade de entrega, do agendamento de retries e do monitoramento de dead-letter. Se você precisar reprocessar um envelope que falhou, o lado emissor deve corrigir o formato do payload e reenviá-lo, em vez de o bot tolerar dados inválidos.

Retries são responsabilidade do produtor

O pacote no lado do bot, @spraxium/signal, intencionalmente não faz retry de payloads inválidos. Se você precisa de novas tentativas de entrega, configure isso no lado emissor com as opções de fallback de @spraxium/signal-client, usando store em arquivo ou Redis.

Testando com createSignalPayload

O pacote @spraxium/signal exporta o helper createSignalPayload, que monta envelopes sem assinatura para fins de teste. Esse helper gera um nonce UUID e um timestamp atual, retornando um UnsignedEnvelope que pode ser usado em testes unitários para verificar o comportamento do handler sem precisar montar todo o pipeline do 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)