Producer-boundary validation with Zod
A trigger’s event payload is typed at compile time, but runtime data — form submissions, URL params, WebSocket frames — comes in unstructured. Triggery’s recommended pattern: validate at the producer’s boundary, fire only what passes. The trigger handler then sees a fully typed, runtime-checked payload. This recipe shows the idiom and packages it as a small useValidatedEvent helper so every producer in the app is a one-liner.
Scenario
Section titled “Scenario”A “create invoice” form submits arbitrary form data. We want to:
- Define one zod schema for the event payload.
- Validate it before firing — if it fails, surface the issues to the form, never reach the trigger.
- Fire a typed event with the parsed (and possibly transformed) payload.
- The trigger handler sees a fully typed value — no
as, no??, no defensive checks.
File layout
Section titled “File layout”Directorysrc/
Directorylib/
- useValidatedEvent.ts the helper
Directorytriggers/
- invoice.trigger.ts
Directoryfeatures/
Directoryinvoice/
- InvoiceForm.tsx producer + the schema
- InvoiceReactor.tsx writes to the API on success
1. The helper
Section titled “1. The helper”A small wrapper around useEvent. It accepts a Standard Schema validator — zod, valibot, arktype, anything that implements the spec — and returns (input) => Result, where Result is either { ok: true; data } or { ok: false; issues }. On success it fires the event with the parsed value.
import type { EventKey, EventMap, Trigger, TriggerSchema } from '@triggery/core';
import { useEvent } from '@triggery/react';
import { useCallback } from 'react';
// Tiny subset of the Standard Schema interface — enough for our purposes.
type StandardSchema<Out> = {
readonly '~standard': {
readonly validate: (input: unknown) =>
| { value: Out; issues?: undefined }
| { issues: readonly { message: string; path?: readonly (PropertyKey | { key: PropertyKey })[] }[] }
| Promise<unknown>;
};
};
export type ValidationResult<T> =
| { ok: true; data: T }
| { ok: false; issues: readonly { message: string; path?: readonly (PropertyKey | { key: PropertyKey })[] }[] };
export function useValidatedEvent<
S extends TriggerSchema,
K extends EventKey<S>,
>(trigger: Trigger<S>, eventName: K, schema: StandardSchema<EventMap<S>[K]>) {
const fire = useEvent(trigger, eventName);
return useCallback(
(input: unknown): ValidationResult<EventMap<S>[K]> => {
const out = schema['~standard'].validate(input);
if (out instanceof Promise) {
// For brevity: keep the helper synchronous. Most schemas (zod/valibot)
// run sync unless you opt into async refinements.
throw new Error('useValidatedEvent: async schemas are not supported');
}
if ('issues' in out && out.issues) {
return { ok: false, issues: out.issues };
}
const data = (out as { value: EventMap<S>[K] }).value;
// Strong assertion: zod's output is the trigger's event payload type.
(fire as (payload: EventMap<S>[K]) => void)(data);
return { ok: true, data };
},
[fire, schema],
);
}That’s it — fewer than 30 lines, type-safe end-to-end. The producer calls it once, the trigger sees only validated payloads.
2. The trigger
Section titled “2. The trigger”The handler treats event.payload as fully typed and clean — no second guessing.
import { createTrigger } from '@triggery/core';
export type Invoice = {
customer: string;
amountCents: number;
dueDate: string; // ISO date
note?: string;
};
export const invoiceTrigger = createTrigger<{
events: {
'invoice:submit': Invoice;
};
actions: {
saveInvoice: Invoice;
};
}>({
id: 'invoice-submit',
events: ['invoice:submit'],
concurrency: 'queue',
handler({ event, actions }) {
actions.saveInvoice?.(event.payload);
},
});concurrency: 'queue' ensures that if the user mashes the submit button, the saves happen in order, one at a time.
3. The producer (form + schema)
Section titled “3. The producer (form + schema)”The schema sits next to the form, where the boundary is. The form calls submit(formData), gets back { ok, data | issues }, and renders accordingly.
import { useState, type FormEvent } from 'react';
import { z } from 'zod';
import { invoiceTrigger } from '../../triggers/invoice.trigger';
import { useValidatedEvent } from '../../lib/useValidatedEvent';
const InvoiceSchema = z.object({
customer: z.string().min(1, 'Customer required'),
amountCents: z.coerce.number().int().positive('Amount must be > 0'),
dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Use YYYY-MM-DD'),
note: z.string().max(280).optional(),
});
export function InvoiceForm() {
const submit = useValidatedEvent(invoiceTrigger, 'invoice:submit', InvoiceSchema);
const [errors, setErrors] = useState<Record<string, string>>({});
function onSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
const fd = new FormData(e.currentTarget);
const result = submit({
customer: fd.get('customer'),
amountCents: fd.get('amountCents'),
dueDate: fd.get('dueDate'),
note: fd.get('note') || undefined,
});
if (result.ok) {
setErrors({});
e.currentTarget.reset();
return;
}
setErrors(
Object.fromEntries(
result.issues.map(i => {
const key = i.path?.[0];
const field = typeof key === 'object' && key !== null && 'key' in key ? String(key.key) : String(key ?? '_');
return [field, i.message];
}),
),
);
}
return (
<form onSubmit={onSubmit}>
<label>
Customer
<input name="customer" />
{errors.customer && <em role="alert">{errors.customer}</em>}
</label>
<label>
Amount (cents)
<input name="amountCents" inputMode="numeric" />
{errors.amountCents && <em role="alert">{errors.amountCents}</em>}
</label>
<label>
Due date
<input name="dueDate" placeholder="YYYY-MM-DD" />
{errors.dueDate && <em role="alert">{errors.dueDate}</em>}
</label>
<label>
Note
<textarea name="note" maxLength={280} />
</label>
<button type="submit">Create invoice</button>
</form>
);
}The trigger handler will never see an invalid amountCents, a missing customer, or a malformed dueDate. If you change the schema, you change a single file.
4. The reactor
Section titled “4. The reactor”import { useAction } from '@triggery/react';
import { invoiceTrigger, type Invoice } from '../../triggers/invoice.trigger';
async function postInvoice(invoice: Invoice) {
const res = await fetch('/api/invoices', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(invoice),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
}
export function InvoiceReactor() {
useAction(invoiceTrigger, 'saveInvoice', async invoice => {
await postInvoice(invoice);
});
return null;
}Why validate at the producer
Section titled “Why validate at the producer”There are three places you could validate: the producer (before firing), inside the trigger handler, or inside an action.
Producer-boundary validation is the right default:
- The schema is near the form — the only place input is unstructured.
- Failures stay UI-shaped (return the issues to the form) instead of becoming runtime exceptions.
- The trigger’s event payload type is true — handlers don’t have to defend against malformed data, and the discriminated union on
event.namekeeps its meaning. - Adding new producers for the same event (a different form, a CLI, a deep link) means writing — and re-using — exactly one schema per event.
Validating inside the trigger conflates “is this data well-formed?” (a producer concern) with “what should we do about it?” (a domain concern). Don’t.