webroad.online
  1. 1Web
  2. 2HTML
  3. 3CSS
  4. 4JavaScript
  5. 5TypeScript
  6. 6Git
  7. 7Tooling
  8. 8React
  9. 9State management
  10. 10Next.js
  11. 11Forms
  12. 12Data and backend
  13. 13SEO
  14. 14Tailwind CSS
  15. 15Animations
  16. 16Testing
  17. 17Architecture
Forms · Lesson 3 of 4

Validation with Zod

Schemas, safeParse, coerce for FormData, per-field errors, one schema on the client and the server.

Updated

What validating with a schema is

The data coming into the app (forms, query params, API responses, JSON) is of the "I don't know what this is" kind — unknown. Validation checks that it has the expected shape and rules before you use it.

A schema describes the shape of the data once, as an object. From it you get three things:

  1. runtime validation (in the browser and on the server);
  2. error messages per field;
  3. the TypeScript type, inferred automatically — you don't write it by hand anymore.

Zod is the standard library for this in the TypeScript ecosystem.

A schema

import { z } from 'zod'

export const signupSchema = z.object({
  name: z.string().trim().min(2, 'The name is too short'),
  email: z.email('Invalid email'),
  age: z.coerce.number().int().min(16, 'At least 16 years old'),
  password: z.string().min(8, 'At least 8 characters'),
  terms: z.literal('on', 'You must accept the terms'),
})

export type SignupInput = z.infer<typeof signupSchema>
// { name: string; email: string; age: number; password: string; terms: 'on' }

The basic types — and what they solve

Schema Validates Note
z.string(), z.number(), z.boolean() the type
z.email(), z.url(), z.uuid() string formats in Zod 4 they're top-level; z.string().email() is deprecated
.min(), .max(), .regex() rules they take the message as the second argument
.trim(), .toLowerCase() transform the value the result is already cleaned
z.coerce.number() converts '42' → 42 before validating essential for FormData (everything is a string)
z.enum(['admin', 'editor']) one of the values
.optional(), .nullable(), .default(x) missing values
z.array(schema), z.object({...}) collections, nested objects

parse vs safeParse

schema.parse(data) schema.safeParse(data)
Valid data returns the data { success: true, data }
Invalid data throws a ZodError { success: false, error } — doesn't throw
When data that must be correct (config, env) — an error is a bug user input — errors are normal
const result = signupSchema.safeParse(Object.fromEntries(formData))
if (!result.success) {
  return { errors: z.flattenError(result.error).fieldErrors }
  // { email: ['Invalid email'], age: ['At least 16 years old'] }
}
await createUser(result.data)          // typed, cleaned, converted
Formatting errors Result When
z.flattenError(error) { formErrors: [], fieldErrors: { email: [...] } } simple (one-level) forms
z.treeifyError(error) a tree that follows the schema's shape nested objects, lists

Rules involving several fields

const schema = z
  .object({ password: z.string().min(8), confirm: z.string() })
  .refine(d => d.password === d.confirm, { message: "The passwords don't match", path: ['confirm'] })

path puts the error on the right field.

One schema, two places

The biggest benefit: the same schema on the client (fast feedback in the form) and on the server (security).

  • src/
    • features/
      • signup/
        • model/
          • signup-schema.tsthe Zod schema + the inferred type
        • ui/
          • signup-form.tsxuseForm({ resolver: zodResolver(signupSchema) })
        • api/
          • signup-action.ts'use server' → signupSchema.safeParse(...)

Client-side validation can be bypassed at any time. The server-side one is the one that counts.

Alternatives

Library Why you'd pick it
Zod the standard, a huge ecosystem, excellent types
Valibot a modular API, a much smaller bundle for the client
ArkType a syntax close to TypeScript, very fast

All three implement Standard Schema — form libraries accept them the same way.

Summary

  • A schema = validation + messages + a TS type, written once.
  • safeParse for user input; z.coerce for FormData; z.flattenError for per-field errors.
  • The same schema on the client and the server; the server decides.

Official sources

Exercises

Was this page helpful?

One tap — no account needed.