FormData is loose: every field is a string or a File, and TypeScript doesn't know what fields exist. Zod is the canonical answer — declare a schema, parse incoming data, get typed objects (or typed errors).
The shape
Zod schemas describe what valid data looks like: z.object({ email: z.string().email(), age: z.coerce.number().min(18) }). You call .parse() (throws on failure) or .safeParse() (returns a discriminated union of success/failure). Inside an Action, .safeParse lets you return validation errors as part of state.
FormData → typed object
Zod doesn't know about FormData natively, but you can extract fields into a plain object first: Object.fromEntries(formData). Then run safeParse. The result is either { success: true, data } with everything typed, or { success: false, error } with field-level error messages.
Error shape
Zod errors group by field: error.flatten().fieldErrors gives { email: ['invalid email'], age: ['must be a number'] }. Return this object as part of state and your form can highlight each field individually.
Where it composes with the Actions story
Your action becomes: parse → if invalid, return errors; if valid, do the work, return success. The component renders error messages from state directly. No separate validation library, no manual field iteration — Zod gives you typed input and typed errors in one call.
Validation is the contract. A Zod schema is the only place that says 'an email is required, must be lowercase, max 255 chars.' Server and client share the same schema (single source). TypeScript infers the parsed type from the schema, so the rest of your code gets autocomplete and type safety for free.
Replace your manual email regex from the subscribe-form exercise with a Zod schema. Add a name field (required, min length 2) and a referralCode (optional, format [A-Z]{4}-\d{4}). Render per-field errors using the flatten().fieldErrors shape. Confirm: empty submit shows two field errors, malformed email shows email-specific error, valid submit succeeds.
Hint
For the referral code: z.string().regex(/^[A-Z]{4}-\d{4}$/).optional(). The .optional() makes it acceptable to be empty/missing while still validating the format when provided.
Progress
Progress is local-only — sign in to sync across devices.