Zod Validation Utilities
Overview
Production-ready Zod v4 patterns for reusable, type-safe validation with minimal boilerplate. Focuses on modern APIs, predictable error handling, and form integration.
When to Use
-
Defining request/response validation schemas in TypeScript services
-
Parsing untrusted input from APIs, forms, env vars, or external systems
-
Standardizing coercion, transforms, and cross-field validation
-
Building reusable schema utilities across teams
-
Integrating React Hook Form with Zod using zodResolver
Instructions
-
Start with strict object schemas and explicit field constraints
-
Prefer modern Zod v4 APIs and the error option for error messages
-
Use coercion at boundaries (z.coerce.* ) when input types are uncertain
-
Keep business invariants in refine /superRefine close to schema definitions
-
Export both schema and inferred types (z.input /z.output ) for consistency
-
Reuse utility schemas (email, id, dates, pagination) to reduce duplication
Examples
- Modern Zod 4 primitives and object errors
import { z } from "zod";
export const UserIdSchema = z.uuid({ error: "Invalid user id" }); export const EmailSchema = z.email({ error: "Invalid email" }); export const WebsiteSchema = z.url({ error: "Invalid URL" });
export const UserProfileSchema = z.object( { id: UserIdSchema, email: EmailSchema, website: WebsiteSchema.optional(), }, { error: "Invalid user profile payload" } );
- Coercion, preprocess, and transform
import { z } from "zod";
export const PaginationQuerySchema = z.object({ page: z.coerce.number().int().min(1).default(1), pageSize: z.coerce.number().int().min(1).max(100).default(20), includeArchived: z.coerce.boolean().default(false), });
export const DateFromUnknownSchema = z.preprocess( (value) => (typeof value === "string" || value instanceof Date ? value : undefined), z.coerce.date({ error: "Invalid date" }) );
export const NormalizedEmailSchema = z .string() .trim() .toLowerCase() .email({ error: "Invalid email" }) .transform((value) => value as Lowercase<string>);
- Complex schema structures
import { z } from "zod";
const TagSchema = z.string().trim().min(1).max(40);
export const ProductSchema = z.object({ sku: z.string().min(3).max(24), tags: z.array(TagSchema).max(15), attributes: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])), dimensions: z.tuple([z.number().positive(), z.number().positive(), z.number().positive()]), });
export const PaymentMethodSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal("card"), last4: z.string().regex(/^\d{4}$/) }), z.object({ type: z.literal("paypal"), email: z.email() }), z.object({ type: z.literal("wire"), iban: z.string().min(10) }), ]);
- refine and superRefine
import { z } from "zod";
export const PasswordSchema = z .string() .min(12) .refine((v) => /[A-Z]/.test(v), { error: "Must include an uppercase letter" }) .refine((v) => /\d/.test(v), { error: "Must include a number" });
export const RegisterSchema = z .object({ email: z.email(), password: PasswordSchema, confirmPassword: z.string(), }) .superRefine((data, ctx) => { if (data.password !== data.confirmPassword) { ctx.addIssue({ code: "custom", path: ["confirmPassword"], message: "Passwords do not match", }); } });
- Optional, nullable, nullish, and default
import { z } from "zod";
export const UserPreferencesSchema = z.object({ nickname: z.string().min(2).optional(), // undefined allowed bio: z.string().max(280).nullable(), // null allowed avatarUrl: z.url().nullish(), // null or undefined allowed locale: z.string().default("en"), // fallback when missing });
- React Hook Form integration (zodResolver )
import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod";
const ProfileFormSchema = z.object({ name: z.string().min(2, { error: "Name too short" }), email: z.email({ error: "Invalid email" }), age: z.coerce.number().int().min(18), });
type ProfileFormInput = z.input<typeof ProfileFormSchema>; type ProfileFormOutput = z.output<typeof ProfileFormSchema>;
const form = useForm<ProfileFormInput, unknown, ProfileFormOutput>({ resolver: zodResolver(ProfileFormSchema), criteriaMode: "all", });
Best Practices
-
Keep schemas near boundaries (HTTP handlers, queues, config loaders)
-
Prefer safeParse for recoverable flows; parse for fail-fast execution
-
Share small schema utilities (id , email , slug ) to enforce consistency
-
Use z.input and z.output when transforms/coercions change runtime shape
-
Avoid overusing preprocess ; prefer explicit z.coerce.* where possible
-
Treat external payloads as untrusted and always validate before use
Constraints and Warnings
-
Ensure examples match your installed zod major version (v4 APIs shown)
-
error is the preferred option for custom errors in Zod v4 patterns
-
Discriminated unions require a stable discriminator key across variants
-
Coercion can hide bad upstream data; add bounds and refinements defensively