Skip to Content
Docs06 Zod

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:

  1. validation (required fields, formats, min/max, etc.)
  2. 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.issues to { 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.ts for 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

Last updated on