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:
- runtime validation (in the browser and on the server);
- error messages per field;
- 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.
safeParsefor user input;z.coerceforFormData;z.flattenErrorfor per-field errors.- The same schema on the client and the server; the server decides.