Define your data shape once and use it for form validation, API parsing and TypeScript types.

Bishal Biswas
Self Learner
One definition, three uses
A schema describes what valid data looks like. Once you have written one, it can:
- Validate a form.
- Parse an API response or request body.
- Generate a TypeScript type.
Without a schema you write those three separately and they drift apart.
npm install zod
A schema
import { z } from 'zod';
const signupSchema = z.object({
username: z
.string()
.min(3, 'At least 3 characters')
.max(20, 'At most 20 characters')
.regex(/^[a-zA-Z0-9_]+$/, 'Letters, numbers and underscores only'),
email: z.string().email('That does not look like an email'),
age: z.number().int().min(18, 'You must be 18 or older'),
password: z.string().min(8, 'At least 8 characters'),
confirmPassword: z.string(),
newsletter: z.boolean().default(false)
}).refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword']
});
The messages live with the rules, so there is one place to change them.
refine validates across fields, and path puts the error on the right one.
Parsing
const result = signupSchema.safeParse(formData);
if (!result.success) {
console.log(result.error.issues);
} else {
console.log(result.data);
}
safeParse returns a result object. parse throws instead, which suits a server where an error boundary or middleware will catch it.
Note that result.data is cleaned: defaults are applied, and unknown keys are stripped. So you can trust its shape afterwards.
With React Hook Form
npm install @hookform/resolvers
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
function SignupForm() {
const {
register,
handleSubmit,
formState: { errors }
} = useForm({
resolver: zodResolver(signupSchema),
mode: 'onTouched'
});
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('username')} />
{errors.username && <p>{errors.username.message}</p>}
<input {...register('email')} />
{errors.email && <p>{errors.email.message}</p>}
<input type="number" {...register('age', { valueAsNumber: true })} />
{errors.age && <p>{errors.age.message}</p>}
<button>Sign up</button>
</form>
);
}
The register calls carry no rules at all. The schema is the single source of truth.
Note valueAsNumber. Inputs always give strings, and z.number() would reject "25". Alternatively use z.coerce.number() in the schema.
The same schema on the server
'use server';
import { signupSchema } from './schemas.js';
export async function signup(previousState, formData) {
const parsed = signupSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
return { errors: parsed.error.flatten().fieldErrors };
}
await createAccount(parsed.data);
return { success: true };
}
This is the real payoff. Identical rules on both sides, imported from one file. Change the minimum password length once and both change.
Client validation is for fast feedback. Server validation is the one that counts, because anyone can bypass the browser. With a shared schema you get both for the price of one.
flatten() gives a shape that is easy to render:
{
formErrors: [],
fieldErrors: { email: ['That does not look like an email'] }
}
Types for free
import { z } from 'zod';
export const signupSchema = z.object({
username: z.string().min(3),
email: z.string().email(),
age: z.number().int().min(18)
});
export type SignupData = z.infer<typeof signupSchema>;
z.infer derives the TypeScript type from the schema. There is no separate interface to keep in step, so the type can never disagree with the validation.
Validating API responses
Do not trust data just because it came from your own API.
const productSchema = z.object({
id: z.string(),
name: z.string(),
price: z.number(),
tags: z.array(z.string()).default([])
});
async function getProduct(id) {
const response = await fetch(`/api/products/${id}`);
if (!response.ok) throw new Error('Request failed');
const json = await response.json();
return productSchema.parse(json);
}
If the API changes shape, you get a clear error at the boundary naming the field, instead of Cannot read properties of undefined three components deep.
Useful pieces
z.string().optional();
z.string().nullable();
z.string().default('none');
z.enum(['draft', 'published', 'archived']);
z.array(z.string()).min(1, 'Choose at least one');
z.object({ address: z.object({ city: z.string() }) });
z.union([z.string(), z.number()]);
z.coerce.number();
z.string().transform((s) => s.trim().toLowerCase());
Composing schemas:
const baseUser = z.object({ name: z.string(), email: z.string().email() });
const createUser = baseUser.extend({ password: z.string().min(8) });
const updateUser = baseUser.partial();
const publicUser = baseUser.omit({ email: true });
partial() makes every field optional, which is exactly what a PATCH endpoint wants.
The alternatives
Yup is older and similar, and very common in existing React projects.
Valibot has the same idea with a much smaller bundle, built around tree shaking.
ArkType and TypeBox are alternatives with different trade offs.
Zod is the most widely used and has the best ecosystem support. Any of them beats hand written validation.
Quick recap
- One schema serves form validation, API parsing and TypeScript types.
safeParsereturns a result,parsethrows.- Parsed data is cleaned, with defaults applied and unknown keys removed.
zodResolverconnects a schema to React Hook Form.- Number inputs need
valueAsNumberorz.coerce.number(). - Share the same schema with the server, which is the validation that counts.
z.inferderives the type, so it can never drift.- Validate API responses so errors surface at the boundary.
import { z } from 'zod';
// ONE schema, used for the form, the server and the types.
export const signupSchema = z
.object({
username: z
.string()
.min(3, 'At least 3 characters')
.regex(/^[a-zA-Z0-9_]+$/, 'Letters, numbers and underscores only'),
email: z.string().email('That does not look like an email'),
// Inputs always give strings, so coerce, or use valueAsNumber
age: z.coerce.number().int().min(18, 'You must be 18 or older'),
password: z.string().min(8, 'At least 8 characters'),
confirmPassword: z.string(),
newsletter: z.boolean().default(false)
})
// refine validates ACROSS fields, and path puts the error on the right one
.refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword']
});
// The type is DERIVED, so it can never disagree with the validation
export type SignupData = z.infer<typeof signupSchema>;
// ---------- The form: no rules on register at all ----------
// import { useForm } from 'react-hook-form';
// import { zodResolver } from '@hookform/resolvers/zod';
// const { register, handleSubmit, formState: { errors } } = useForm({
// resolver: zodResolver(signupSchema),
// mode: 'onTouched'
// });
// <input {...register('username')} />
// {errors.username && <p>{errors.username.message}</p>}
// ---------- The server: the SAME schema, imported ----------
// 'use server';
// export async function signup(previousState, formData) {
// const parsed = signupSchema.safeParse(Object.fromEntries(formData));
// if (!parsed.success) {
// return { errors: parsed.error.flatten().fieldErrors };
// }
// await createAccount(parsed.data); cleaned, defaults applied
// return { success: true };
// }
// Client validation is for fast feedback. SERVER validation is the one
// that counts, because anyone can bypass the browser.
// ---------- Validate API responses too ----------
const productSchema = z.object({
id: z.string(),
name: z.string(),
price: z.number(),
tags: z.array(z.string()).default([])
});
export async function getProduct(id: string) {
const response = await fetch('/api/products/' + id);
if (!response.ok) throw new Error('Request failed');
// A clear error at the boundary naming the field, instead of
// "Cannot read properties of undefined" three components deep
return productSchema.parse(await response.json());
}
// ---------- Compose rather than repeat ----------
const baseUser = z.object({ name: z.string(), email: z.string().email() });
const createUser = baseUser.extend({ password: z.string().min(8) });
const updateUser = baseUser.partial(); // every field optional, for PATCH
const publicUser = baseUser.omit({ email: true });
Was this explanation helpful?
Your feedback helps us improve the content for everyone.
Thank you for your feedback!
We appreciate you helping us improve.
What could we improve? (Optional)
Read aloud
Ready