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.
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.
src/lib/useValidatedEvent.ts
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.
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.
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.name keeps 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.