Why Zod belongs at the Signal boundary
Bot listeners are where external events become internal side effects. Role changes, configuration updates, moderation actions, and billing mutations all start with an incoming payload. Using Zod at this boundary lets you reject malformed data before it reaches business services. This is not optional for production bots because the payload originates from external systems that may evolve independently, send unexpected shapes, or contain invalid values due to bugs in the emitter code.
The @OnSignal decorator accepts a schema option that takes any Zod schema. When present, the SignalProcessor calls safeParse on the envelope payload before invoking the handler. If validation fails, the handler is never called and a diagnostic log is emitted when debug mode is enabled. If no schema is configured, the raw payload is passed through unchanged, which is useful for handlers that perform their own parsing logic.
Validate at the boundary, not inside handlers
Attaching the schema to @OnSignal ensures that every handler
invocation receives validated and typed data. This eliminates defensive checks
inside business code and makes handler signatures trustworthy.
Schema design and listener setup
The recommended pattern is to define schemas in dedicated files and import them in your listener classes. This separation keeps schemas reusable across handlers and testable independently. Each schema file exports both the Zod schema constant and the inferred TypeScript type.
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>;Validation flow in the processor
When the SignalProcessor receives a validated envelope, it checks whether the resolved handler has a schema attached. If it does, the processor calls schema.safeParse(envelope.payload) and inspects the result. On success, result.data replaces the raw payload and is passed to the handler. On failure, the handler is skipped entirely and a diagnostic log entry is emitted with the Zod issue details.
This flow means your handler is guaranteed to receive data that matches the schema. There is no need for try-catch blocks around payload access, no need for manual type assertions, and no risk of undefined properties causing runtime errors. The schema acts as a compile-time and runtime contract simultaneously.
Schema presence check
The processor checks whether the handler has a Zod schema. If no schema is attached, the raw envelope.payload is passed to the handler unchanged. This allows handlers that do not need validation to skip the overhead entirely.
Safe parse execution
The processor calls schema.safeParse(envelope.payload) which never throws. It returns a discriminated union: either { success: true, data: T } with the parsed and typed payload, or { success: false, error: ZodError } with the validation issues.
Success path
On successful validation, result.data replaces the raw payload. This parsed version has defaults applied, transforms executed, and types narrowed. The handler receives this typed value as its first argument.
Failure path
On failed validation, the handler is never invoked. If debug mode is enabled, the processor logs the Zod issues as a JSON array, including the path, expected type, and received value for each failing field. The envelope is silently dropped to prevent corrupt data from reaching business logic.
Multiple handlers with different schemas
A single @SignalListener class can contain multiple @OnSignal methods with different event names and different schemas. Each handler is registered independently in the SignalRouter, and each schema is validated independently during dispatch. This allows you to group related domain handlers in one class while maintaining strict payload contracts per event.
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
}
}Schema modeling guidelines
Durable schemas follow a set of principles that minimize breaking changes between producer and consumer deployments. Because the emitter and the bot may be deployed independently, schema evolution must be handled carefully.
| Principle | Practice | Reason |
|---|---|---|
| Additive changes | Add new fields as | Allows gradual rollout between emitter and bot deployments. |
| Small payloads | Include only fields the bot actually needs for decision-making. | Reduces coupling and makes schemas easier to maintain over time. |
| Strict bounds | Use | Catches invalid data early and prevents downstream logic errors. |
| Versioned event names | Use | Preserves backward compatibility during transition periods. |
| Explicit enums | Use | Prevents typos and undocumented values from reaching handlers. |
Advanced schema patterns
For complex domain events, Zod supports powerful composition patterns that keep schemas DRY and expressive. Discriminated unions let you validate different payload shapes based on a shared discriminator field. Intersections let you combine base fields with event-specific extensions.
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>;Retry responsibilities
The bot-side @spraxium/signal package does not include an internal retry queue or worker. If a Zod validation fails, the envelope is dropped, and no automatic retry is attempted. This is by design because the bot should remain a deterministic event consumer. Retry logic belongs on the emitter side, where the @spraxium/signal-client package provides fallback persistence with exponential backoff.
This separation keeps each side focused on its responsibility. The bot validates and dispatches trusted envelopes. The emitter handles delivery reliability, retry scheduling, and dead-letter monitoring. If you need to reprocess a failed envelope, the emitter should fix the payload shape and resend it rather than the bot tolerating invalid data.
Retry ownership is producer-side
Bot-side @spraxium/signal intentionally does not retry failed
payloads. If delivery retries are needed, configure them on the emitter side
using
@spraxium/signal-client fallback settings with file or Redis
store.
Testing with createSignalPayload
The @spraxium/signal package exports a createSignalPayload helper that builds unsigned envelopes for testing purposes. This helper generates a UUID nonce and a current timestamp, returning an UnsignedEnvelope that can be used in unit tests to verify handler behavior without setting up the full Signal pipeline.
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)