React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

zodvalidationschemaparsesafeParseerror-handling

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
  • safeParse for non-throwing validation
  • flatten() to get field-level error messages
  • z.infer to 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 a ZodError containing an issues array
  • safeParse() 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
  • 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

  • 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 FormDataFormData.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 silentlyz.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.

Alternatives

AlternativeUse WhenDon't Use When
YupYou want a Formik-native schema library with a similar APIYou need top-tier TypeScript inference
ValibotYou need the smallest possible bundle sizeYou rely on Zod's extensive ecosystem integrations
ArkTypeYou want type-level validation with near-zero runtime overheadYou need widespread community support and examples
Manual validationOne-off checks with trivial logicYou 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 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.
  • 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 a ZodError
  • safeParse() never throws; it returns { success: true, data } or { success: false, error }
  • Use safeParse in 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 forms
  • format() returns a nested object matching the schema shape with _errors arrays -- 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() allows undefined (type becomes T | undefined)
  • .nullable() allows null (type becomes T | null)
  • .nullish() allows both (type becomes T | 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.

  • 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