Zod Basics
Define schemas, validate data at runtime, and handle errors — the foundation of type-safe validation in TypeScript.
Recipe
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.
Working Example
"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:
- Schema definition with chained validators
safeParsefor non-throwing validationflatten()to get field-level error messagesz.inferto derive TypeScript types from schemas
Deep Dive
How It Works
- Zod schemas are immutable objects — every method returns a new schema instance
parse()returns the validated data or throws aZodErrorcontaining anissuesarraysafeParse()returns a discriminated union:{ success: true, data }or{ success: false, error }ZodError.flatten()groups errors intoformErrors(root) andfieldErrors(per-field arrays)ZodError.format()returns a nested object matching the schema shape, useful for deep objects- All schemas strip unknown keys from objects by default (use
.passthrough()or.strict()to change)
Variations
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[] } } }
}TypeScript Notes
// 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);
}Gotchas
-
parsevssafeParsein server actions — Usingparseinside a server action will throw an unhandled error. Fix: Always usesafeParsein server actions and return structured errors to the client. -
String coercion from FormData —
FormData.get()returnsstring | File | null, but your schema expectsnumber. Fix: Usez.coerce.number()orz.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
errorMapfor i18n.
Alternatives
| 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 |
Real-World Example
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 beundefined(omitted from the input)..nullable()means it can be explicitlynull. These are different:imageUrlmust be present but can benull, whilelinkUrlcan be left out entirely..optional().default('')makes a field optional in the input but guarantees a value in the output. After parsing,subtitleis always astring, neverundefined..refine()enables cross-field validation thatz.string()orz.date()alone cannot express. Thepath: ['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) intoDateobjects automatically. Without coercion, passing a string to az.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.- The schema handles both API request validation (server actions) and form validation (client-side) with the same definition. One schema, two contexts.
FAQs
What is the difference between parse() and safeParse()?
parse()returns the validated data or throws aZodErrorsafeParse()never throws; it returns{ success: true, data }or{ success: false, error }- Use
safeParsein server actions and anywhere you need to handle errors gracefully
How do you get field-level error messages from a ZodError?
const result = schema.safeParse(data);
if (!result.success) {
const flat = result.error.flatten();
// flat.fieldErrors = { name?: string[], email?: string[] }
}What is the difference between flatten() and format() on ZodError?
flatten()returns{ formErrors: string[], fieldErrors: { [key]: string[] } }-- best for simple formsformat()returns a nested object matching the schema shape with_errorsarrays -- best for deeply nested objects
How does z.infer derive a TypeScript type from a schema?
const UserSchema = z.object({
name: z.string(),
age: z.number(),
});
type User = z.infer<typeof UserSchema>;
// { name: string; age: number }What does z.object() do with unknown keys by default?
It strips them silently. Use .passthrough() to keep extra keys or .strict() to reject them with an error.
How do you handle form fields that submit strings but your schema expects numbers?
Use z.coerce.number() which calls Number(value) before validating, converting the string to a number automatically.
What is the difference between .optional(), .nullable(), and .nullish()?
.optional()allowsundefined(type becomesT | undefined).nullable()allowsnull(type becomesT | null).nullish()allows both (type becomesT | null | undefined)
How do you set a custom global error map for i18n?
z.setErrorMap((issue, ctx) => {
if (issue.code === z.ZodIssueCode.too_small) {
return { message: `Minimum length is ${issue.minimum}` };
}
return { message: ctx.defaultError };
});Gotcha: Why does using parse() in a Server Action cause unhandled errors?
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.
Gotcha: Why does z.coerce.date() accept arbitrary numbers?
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.
What does .refine() do on an object schema?
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.
TypeScript: How do you accept any Zod schema as a function parameter?
function validate<T>(schema: z.ZodType<T>, data: unknown): T {
return schema.parse(data);
}TypeScript: Can you type a ZodError with the schema's inferred type?
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.
Related
- Zod Types — strings, numbers, arrays, objects, enums, unions
- Zod Transforms — transform, refine, superRefine, preprocess
- Zod Infer — deriving TypeScript types from schemas
- RHF + Zod — integrating Zod with react-hook-form