Best Practices

AI Rules for Form Validation

AI validates with manual if-statements and shows one error at a time. Rules for schema-based validation (Zod/Yup), field-level errors, accessible error display, and client+server validation.

7 min read·January 20, 2025

8 lines of if-statements that Zod expresses in 3 — with TypeScript types for free

Schema validation, React Hook Form, field-level errors, accessibility, and server validation

AI Validates Forms Like It Is 2005

AI generates form validation with: manual if-statements (if (!email) setError("Email required")), one error at a time (user fixes one error, submits, sees the next), no type safety (validation logic does not match the form schema), client-only validation (server trusts the client — never safe), and inaccessible error display (red text with no ARIA association). Every one of these patterns has been solved by modern validation libraries — AI just does not use them.

Modern form validation is: schema-based (one Zod/Yup schema defines all rules), field-level (all errors shown at once, per field), type-safe (the schema generates the TypeScript type), accessible (aria-describedby connects errors to inputs), and dual-layer (validated on client for UX, validated on server for security). AI generates none of these.

These rules cover: schema-based validation (Zod, Yup), form library integration (React Hook Form, Formik), field-level error display, accessibility patterns, and server-side validation.

Rule 1: Schema-Based Validation with Zod or Yup

The rule: 'Define validation rules as a schema — not as if-statements in the submit handler. Zod: const schema = z.object({ email: z.string().email("Invalid email"), password: z.string().min(8, "At least 8 characters"), name: z.string().min(1, "Name required") }). The schema is: the validation logic (runtime), the TypeScript type (compile time), and the documentation (what fields exist and their constraints). One definition, three purposes.'

For Zod vs Yup: 'Zod: TypeScript-first, tree-shakeable, z.infer<typeof schema> for types. Yup: older, larger ecosystem, similar API. Both work with React Hook Form via resolvers: useForm({ resolver: zodResolver(schema) }). Choose Zod for new projects (better TypeScript integration). Use Yup if your project already uses it. Never mix both in one project.'

AI generates: if (!email) errors.push("Email required"); if (!email.includes("@")) errors.push("Invalid email"); if (!password) errors.push("Password required"); if (password.length < 8) errors.push("Too short"); — 8 lines that Zod expresses in 3. And the Zod version generates TypeScript types automatically.

  • Zod schema: one definition = validation rules + TypeScript type + documentation
  • z.string().email() — z.string().min(8) — z.number().positive() — declarative
  • z.infer<typeof schema> for TypeScript type — always in sync with validation
  • zodResolver for React Hook Form — yupResolver for Yup — one line integration
  • Never if-statements for validation — schema is declarative, composable, reusable
💡 8 Lines → 3 Lines

Manual validation: if (!email) errors.push(...); if (!email.includes('@')) errors.push(...); — 8 lines per form. Zod: z.object({ email: z.string().email(), password: z.string().min(8) }) — 3 lines, plus automatic TypeScript types.

Rule 2: React Hook Form for Form State

The rule: 'Use React Hook Form for form state management: const { register, handleSubmit, formState: { errors } } = useForm<FormData>({ resolver: zodResolver(schema) }). Register inputs: <input {...register("email")} />. Display errors: {errors.email && <span>{errors.email.message}</span>}. Never use useState for each form field — React Hook Form handles: state, validation, submission, dirty tracking, and performance (uncontrolled inputs, no re-render per keystroke).'

For controlled components: 'Use Controller for components that do not accept ref (Select, DatePicker, rich text): <Controller name="role" control={control} render={({ field }) => <Select {...field} options={roles} />} />. Controller bridges React Hook Form with controlled components — the form library manages state, the component renders it.'

AI generates: const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [errors, setErrors] = useState({}); — three state variables per field, manual validation in onSubmit, re-render on every keystroke. React Hook Form replaces all of this with one hook — no per-field state, no manual validation, no keystroke re-renders.

⚠️ useState Per Field

AI generates const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); — three state variables per field, re-render on every keystroke. React Hook Form: one useForm() call, uncontrolled inputs, zero keystroke re-renders.

Rule 3: Field-Level Errors — All at Once

The rule: 'Show all field errors simultaneously — never one at a time. When the user submits, validate the entire form and display errors next to each invalid field. Users can fix all errors in one pass instead of submitting 5 times to discover 5 errors. React Hook Form mode: "onBlur" or "onSubmit" — validate when the field loses focus or on submit, not on every keystroke (too aggressive).'

For inline errors: 'Display the error message directly below the field: <input {...register("email")} aria-invalid={!!errors.email} aria-describedby={errors.email ? "email-error" : undefined} /> {errors.email && <p id="email-error" role="alert">{errors.email.message}</p>}. The error is: visually adjacent (below the field), programmatically associated (aria-describedby), and announced to screen readers (role="alert").'

AI shows errors in a list at the top of the form or as a single alert: "Please fix the errors below." The user scrolls up, reads the list, scrolls down, forgets which field was wrong, scrolls up again. Inline errors next to each field eliminate this — the error is where the fix needs to happen.

Rule 4: Accessible Error Display

The rule: 'Every form input has: a visible <label> with htmlFor matching the input id, aria-invalid="true" when the field has an error, aria-describedby pointing to the error message element, and the error message has role="alert" for screen reader announcement. Never use: placeholder as label (disappears on type), color alone for errors (colorblind users miss it), or tooltip errors (invisible to keyboard users).'

For required fields: 'Mark required fields with: aria-required="true" on the input, and a visible asterisk (*) in the label (with a legend explaining: "* = required"). Do not rely on the asterisk alone — screen readers need aria-required for programmatic detection. HTML required attribute provides native validation — use alongside your schema validation for progressive enhancement.'

AI generates: <input style={{borderColor: "red"}} /> — a red border with no label, no aria attributes, and no error message. Screen readers announce nothing. Colorblind users see nothing. Keyboard users cannot discover the error. Three attributes (aria-invalid, aria-describedby, role="alert") make the form accessible to everyone.

  • Visible <label> with htmlFor — never placeholder as label
  • aria-invalid='true' on error — aria-describedby pointing to error message
  • role='alert' on error message — screen readers announce immediately
  • aria-required='true' on required fields — visible asterisk with legend
  • Never color alone for errors — text + icon + aria for full accessibility
ℹ️ Color-Only Errors

A red border with no label, no ARIA attributes, and no error message. Screen readers announce nothing. Colorblind users see nothing. Three attributes — aria-invalid, aria-describedby, role='alert' — make the form accessible to everyone.

Rule 5: Server-Side Validation — Never Trust the Client

The rule: 'Validate on the server regardless of client-side validation. Client validation is for UX — it provides instant feedback. Server validation is for security — it prevents malicious input. A user can bypass client validation by: disabling JavaScript, modifying the request with DevTools, or using curl. The server must validate independently — using the same Zod schema (share between client and server).'

For the shared schema: 'Define the Zod schema in a shared package or file: export const createUserSchema = z.object({ email: z.string().email(), name: z.string().min(1) }). Client: useForm({ resolver: zodResolver(createUserSchema) }). Server: const validated = createUserSchema.parse(req.body). One schema, two validation points — always in sync, no duplication.'

For server errors: 'When server validation fails (or the server catches a business rule violation the client cannot check), return structured errors that the client can map to fields: { errors: { email: "Already registered", name: null } }. React Hook Form setError: setError("email", { message: "Already registered" }). This shows the server error inline, next to the field — identical to client validation errors.'

Complete Form Validation Rules Template

Consolidated rules for form validation.

  • Schema-based: Zod/Yup — one schema = validation + TypeScript type + documentation
  • React Hook Form: useForm + zodResolver — no per-field useState, no keystroke re-renders
  • Field-level errors: all at once, inline below each field — never one-at-a-time or top-of-form
  • Accessible: label + aria-invalid + aria-describedby + role='alert' — never color-only errors
  • Server validation: same Zod schema — never trust client — shared schema, two validation points
  • Server errors mapped to fields: setError('email', { message }) — inline display
  • mode: 'onBlur' or 'onSubmit' — not 'onChange' (too aggressive for most forms)
  • Controller for non-ref components — register for native inputs