Zod: validation contracts (forms + tRPC)
Zod is our standard way to define validation contracts for inputs and outputs. It gives us:
- runtime validation (not just TypeScript types)
- readable error messages for users
- type inference for TS (
z.infer<>)
The big idea
- TypeScript types disappear at runtime.
- Zod runs at runtime, so it can reject bad inputs coming from browsers, scripts, or unexpected clients.
How it ties to forms
Forms need two things:
- validation (required fields, formats, min/max, etc.)
- error mapping (show the right error next to the right field)
Validation can run at different times (choose based on UX):
- onChange: fastest feedback, but can be noisy (often add debouncing)
- onBlur: good balance (validate when the user leaves a field)
- onSubmit: final gate (always validate all fields here)
Debouncing validation (why and when)
If you validate on every keystroke (onChange), you can get:
- jumpy UX (errors flashing while the user is still typing)
- wasted work (re-validating dozens of times per second)
- extra network traffic if you do async checks (e.g. “is this email already taken?”)
Debouncing means: “wait a short delay after the last input before running the work”. If new input happens before the delay ends, the timer resets.
Typical patterns:
- Zod sync validation (fast): validate on blur, or debounced on change (e.g. 150–300ms).
- Async validation (slower): almost always debounced (e.g. 300–600ms), and cancel/ignore stale results.
Exercise:
content/exercises/01-react/02-debouncing-validation-and-search.mdx
Recommended flow (simple onSubmit validation):
- define a Zod schema for form data
- parse on submit (
safeParse) - map
ZodError.issuesto{ fieldName: message }
Alternative flow (validate as the user types / blurs, plus onSubmit):
Zod form validation is client-side UX, not security. You still validate again at the server boundary.
How it ties to tRPC
tRPC is our app’s internal API boundary. The boundary must be protected:
- validate inputs (Zod)
- authenticate/authorize (middleware)
- call microservices server-side only
Recommended flow:
Standard pattern (what we’ll converge on)
- Put schemas close to the procedure or feature:
schemas.tsfor a feature, or- co-located next to the router/procedure
- Prefer reusing the same schema for:
- form validation (client)
- procedure input validation (server) when it doesn’t create weird coupling
Sources
- Zod docs: https://zod.dev/
- Zod error format: https://zod.dev/ERROR_HANDLING
Last updated on