Zod Basics
Define schemas, validate data at runtime, and handle errors - the foundation of type-safe validation in TypeScript.
Search across all documentation pages
Define schemas, validate data at runtime, and handle errors - the foundation of type-safe validation in TypeScript.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
Quick-reference recipe card - copy-paste ready.
import { z } from "zod";
// 1. Define a schema
const UserSchema = z.object({
name: z.string().min(1, "Name is required"),
email: z.string().email("Invalid email"),
age: z.number().int().min(18, "Must be 18+"),
});
// 2. Parse (throws on failure)
try {
const user = UserSchema.parse({ name: "Ada", email: "ada@example.com", age: 30 });
console.log(user); // typed as { name: string; email: string; age: number }
} catch (err) {
if (err instanceof z.ZodError) {
console.error(err.issues);
}
}
// 3. safeParse (never throws)
const result = UserSchema.safeParse({ name: "", email: "bad", age: 15 });
if (!result.success) {
console.error(result.error.flatten());
} else {
console.log(result.data);
}When to reach for this: Whenever you need runtime validation of user input, API responses, environment variables, or any untrusted data boundary.
"use client";
import { useState } from "react";
import { z } from "zod";
const ContactSchema = z.object({
name: z.string().min(1, "Name is required").max(100, "Name too long"),
email: z.string().email("Please enter a valid email"),
message: z
.string()
.min(10, "Message must be at least 10 characters")
.max(1000, "Message too long"),
});
type ContactData = z.infer<typeof ContactSchema>;
export function ContactValidator() {
const [errors, setErrors] = useState<Record<string, string[]>>({});
const [success, setSuccess] = useState<ContactData | null>(null);
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const raw = {
name: formData.get("name"),
email: formData.get("email"),
message: formData.get("message"),
};
const result = ContactSchema.safeParse(raw);
if (!result.success) {
const flat = result.error.flatten();
setErrors(flat.fieldErrors as Record<string, string[]>);
setSuccess(null);
} else {
setErrors({});
setSuccess(result.data);
}
}
return (
<form onSubmit={handleSubmit} className="flex max-w-md flex-col gap-3">
<div>
<input name="name" placeholder="Name" className="w-full rounded border p-2" />
{errors.name && <p className="text-sm text-red-600">{errors.name[0]}</p>}
</div>
<div>
<input name="email" placeholder="Email" className="w-full rounded border p-2" />
{errors.email && <p className="text-sm text-red-600">{errors.email[0]}</p>}
</div>
<div>
<textarea name="message" placeholder="Message" className="w-full rounded border p-2" />
{errors.message && <p className="text-sm text-red-600">{errors.message[0]}</p>}
</div>
<button type="submit" className="rounded bg-blue-600 px-4 py-2 text-white">
Validate
</button>
{success && (
<pre className="rounded bg-green-50 p-3 text-sm">{JSON.stringify(success, null, 2)}</pre>
)}
</form>
);
}What this demonstrates:
safeParse for non-throwing validationflatten() to get field-level error messagesz.infer to derive TypeScript types from schemasparse() returns the validated data or throws a ZodError containing an issues arraysafeParse() returns a discriminated union: { success: true, data } or { success: false, error }ZodError.flatten() groups errors into formErrors (root) and fieldErrors (per-field arrays)ZodError.format() returns a nested object matching the schema shape, useful for deep objects.passthrough() or .strict() to change)Custom error maps:
const schema = z.string({
required_error: "This field is required",
invalid_type_error: "Expected a string",
}).min(1, { message: "Cannot be empty" });Global error map:
z.setErrorMap((issue, ctx) => {
if (issue.code === z.ZodIssueCode.too_small) {
return { message: `Minimum length is ${issue.minimum}` };
}
return { message: ctx.defaultError };
});Flattened vs formatted errors:
const result = schema.safeParse(data);
if (!result.success) {
// Flat - great for simple forms
result.error.flatten();
// { formErrors: string[], fieldErrors: { name?: string[], email?: string[] } }
// Formatted - great for nested objects
result.error.format();
// { name: { _errors: string[] }, address: { city: { _errors: string[] } } }
}// The inferred type matches the schema exactly
type User = z.infer<typeof UserSchema>;
// { name: string; email: string; age: number }
// ZodError is generic - you can type it
const result = UserSchema.safeParse(data);
if (!result.success) {
const err: z.ZodError<User> = result.error;
}
// Use z.ZodType to accept any schema as a parameter
function validate<T>(schema: z.ZodType<T>, data: unknown): T {
return schema.parse(data);
}parse vs safeParse in server actions - Using parse inside a server action will throw an unhandled error. Fix: Always use safeParse in server actions and return structured errors to the client.
String coercion from FormData - FormData.get() returns string | File | null, but your schema expects number. Fix: Use z.coerce.number() or z.string().pipe(z.coerce.number()) when parsing form data.
Stripping unknown keys silently - z.object() drops extra keys by default. If you need them, use .passthrough(). If you want to reject them, use .strict().
Error message locale - Zod error messages default to English. Fix: Use a custom errorMap for i18n.
| Alternative | Use When | Don't Use When |
|---|---|---|
| Yup | You want a Formik-native schema library with a similar API | You need top-tier TypeScript inference |
| Valibot | You need the smallest possible bundle size | You rely on Zod's extensive ecosystem integrations |
| ArkType | You want type-level validation with near-zero runtime overhead | You need widespread community support and examples |
| Manual validation | One-off checks with trivial logic | You have more than 2-3 fields or nested objects |
From a production Next.js 15 / React 19 SaaS application (SystemsArchitect.io).
// Production example: Banner schema with defaults, nullable, and cross-field validation
// File: src/schemas/banner.ts
import { z } from 'zod';
export const BannerSchema = z.object({
title: z.string().min(1, 'Title is required').max(100),
subtitle: z.string().optional().default(''),
imageUrl: z.string().url('Must be a valid URL').nullable(),
linkUrl: z.string().url('Must be a valid URL').optional(),
linkText: z.string().optional().default('Learn more'),
isActive: z.boolean().default(true),
startDate: z.coerce.date(),
endDate: z.coerce.date().nullable(),
priority: z.number().int().min(0).max(100).default(50),
}).refine(
(data) => {
if (data.endDate && data.startDate) {
return data.endDate > data.startDate;
}
return true;
},
{
message: 'End date must be after start date',
path: ['endDate'],
}
);
// Extract TypeScript type from the schema
export type Banner = z.infer<typeof BannerSchema>;
// {
// title: string;
// subtitle: string; // defaults to ''
// imageUrl: string | null; // nullable
// linkUrl?: string; // optional, no default
// linkText: string; // defaults to 'Learn more'
// isActive: boolean; // defaults to true
// startDate: Date;
// endDate: Date | null;
// priority: number; // defaults to 50
// }What this demonstrates in production:
.optional() means the field can be undefined (omitted from the input). .nullable() means it can be explicitly null. These are different: imageUrl must be present but can be null, while linkUrl can be left out entirely..optional().default('') makes a field optional in the input but guarantees a value in the output. After parsing, subtitle is always a string, never undefined..refine() enables cross-field validation that z.string() or z.date() alone cannot express. The path: ['endDate'] option attaches the error message to the correct field in form error displays.z.coerce.date() converts string inputs (like "2025-01-15" from an HTML date input or JSON payload) into Date objects automatically. Without coercion, passing a string to a z.date() schema would fail.z.infer<typeof BannerSchema> extracts the TypeScript type from the schema. This is the single source of truth. You never manually write a matching interface, which eliminates drift between validation and types.parse() returns the validated data or throws a ZodErrorsafeParse() never throws; it returns { success: true, data } or { success: false, error }safeParse in server actions and anywhere you need to handle errors gracefullyconst result = schema.safeParse(data);
if (!result.success) {
const flat = result.error.flatten();
// flat.fieldErrors = { name?: string[], email?: string[] }
}flatten() returns { formErrors: string[], fieldErrors: { [key]: string[] } } -- best for simple formsformat() returns a nested object matching the schema shape with _errors arrays -- best for deeply nested objectsconst UserSchema = z.object({
name: z.string(),
age: z.number(),
});
type User = z.infer<typeof UserSchema>;
// { name: string; age: number }It strips them silently. Use .passthrough() to keep extra keys or .strict() to reject them with an error.
Use z.coerce.number() which calls Number(value) before validating, converting the string to a number automatically.
.optional() allows undefined (type becomes T | undefined).nullable() allows null (type becomes T | null).nullish() allows both (type becomes T | null | undefined)z.setErrorMap((issue, ctx) => {
if (issue.code === z.ZodIssueCode.too_small) {
return { message: `Minimum length is ${issue.minimum}` };
}
return { message: ctx.defaultError };
});parse() throws a ZodError on failure, which becomes an unhandled server error. Always use safeParse() in server actions and return structured errors to the client.
z.coerce.date() calls new Date(value), so it accepts timestamps and random numbers. If you only want ISO date strings, use z.string().datetime() instead.
It adds cross-field validation. You provide a predicate that receives the full parsed object and returns true/false. Specify path: ["fieldName"] to attach the error to a specific field.
function validate<T>(schema: z.ZodType<T>, data: unknown): T {
return schema.parse(data);
}Yes. z.ZodError<User> gives you a typed error whose issues reference the fields of User. This is useful for type-safe error handling utilities.
Reviewed by Chris St. John·Last updated Jul 10, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥