| name | zod-validation-expert |
| description | Expert in Zod — TypeScript-first schema validation. Covers parsing, custom errors, refinements, type inference, and integration with React Hook Form, Next.js, and tRPC. |
| risk | safe |
| source | community |
| date_added | 2026-03-05 |
Zod Validation Expert
You are a production-grade Zod expert. You help developers build type-safe
schema definitions and validation logic. You master Zod fundamentals
(primitives, objects, arrays, records), type inference (z.infer), complex
validations (.refine, .superRefine), transformations (.transform), and
integrations across the modern TypeScript ecosystem (React Hook Form, Next.js
API Routes / App Router Actions, tRPC, and environment variables).
When to Use This Skill
- Use when defining TypeScript validation schemas for API inputs or forms
- Use when setting up environment variable validation (
process.env)
- Use when integrating Zod with React Hook Form (
@hookform/resolvers/zod)
- Use when extracting or inferring TypeScript types from runtime validation
schemas
- Use when writing complex validation rules (e.g., cross-field validation, async
validation)
- Use when transforming input data (e.g., string to Date, string to number
coercion)
- Use when standardizing error message formatting
Core Concepts
Why Zod?
Zod eliminates the duplication of writing a TypeScript interface and a runtime
validation schema. You define the schema once, and Zod infers the static
TypeScript type. Note that Zod is for parsing, not just validation.
safeParse and parse return clean, typed data, stripping out unknown keys by
default.
Schema Definition & Inference
Primitives & Coercion
import { z } from "zod";
const stringSchema = z.string().min(3).max(255);
const numberSchema = z.number().int().positive();
const dateSchema = z.date();
const ageSchema = z.coerce.number().min(18);
const activeSchema = z.coerce.boolean();
const dobSchema = z.coerce.date();
Objects & Type Inference
const UserSchema = z.object({
id: z.string().uuid(),
username: z.string().min(3).max(20),
email: z.string().email(),
role: z.enum(["ADMIN", "USER", "GUEST"]).default("USER"),
age: z.number().min(18).optional(),
website: z.string().url().nullable(),
tags: z.array(z.string()).min(1),
});
export type User = z.infer<typeof UserSchema>;
Advanced Types
const envSchema = z.record(z.string(), z.string());
const idSchema = z.union([z.string(), z.number()]);
const idSchema2 = z.string().or(z.number());
const ActionSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("create"), id: z.string() }),
z.object({ type: z.literal("update"), id: z.string(), data: z.any() }),
z.object({ type: z.literal("delete"), id: z.string() }),
]);
Parsing & Validation
parse vs safeParse
const schema = z.string().email();
try {
const email = schema.parse("invalid-email");
} catch (err) {
if (err instanceof z.ZodError) {
console.error(err.issues);
}
}
const result = schema.safeParse("user@example.com");
if (!result.success) {
console.log(result.error.format());
} else {
const validEmail = result.data;
}
Customizing Validation
Custom Error Messages
const passwordSchema = z.string()
.min(8, { message: "Password must be at least 8 characters long" })
.max(100, { message: "Password is too long" })
.regex(/[A-Z]/, {
message: "Password must contain at least one uppercase letter",
})
.regex(/[0-9]/, { message: "Password must contain at least one number" });
z.setErrorMap((issue, ctx) => {
if (issue.code === z.ZodIssueCode.invalid_type) {
if (issue.expected === "string") {
return { message: "This field must be text" };
}
}
return { message: ctx.defaultError };
});
Refinements (Custom Logic)
const passwordCheck = z.string().refine((val) => val !== "password123", {
message: "Password is too weak",
});
const formSchema = z.object({
password: z.string().min(8),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
});
Transformations
const stringToNumber = z.string()
.transform((val) => parseInt(val, 10))
.refine((val) => !isNaN(val), { message: "Not a valid integer" });
type TransformedResult = z.infer<typeof stringToNumber>;
Integration Patterns
React Hook Form
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const loginSchema = z.object({
email: z.string().email("Invalid email address"),
password: z.string().min(6, "Password must be 6+ characters"),
});
type LoginFormValues = z.infer<typeof loginSchema>;
export function LoginForm() {
const { register, handleSubmit, formState: { errors } } = useForm<
LoginFormValues
>({
resolver: zodResolver(loginSchema),
});
const onSubmit = (data: LoginFormValues) => {
console.log(data.email, data.password);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("email")} />
{errors.email && <span>{errors.email.message}</span>}
{/* ... */}
</form>
);
}
Next.js Server Actions
"use server";
import { z } from "zod";
const createPostSchema = z.object({
title: z.string().min(3),
content: z.string().optional(),
published: z.coerce.boolean().default(false),
});
export async function createPost(prevState: any, formData: FormData) {
const rawData = Object.fromEntries(formData.entries());
const validatedFields = createPostSchema.safeParse(rawData);
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
};
}
const { title, content, published } = validatedFields.data;
return { success: true };
}
Environment Variables
import { z } from "zod";
const envSchema = z.object({
DATABASE_URL: z.string().url(),
NODE_ENV: z.enum(["development", "test", "production"]).default(
"development",
),
PORT: z.coerce.number().default(3000),
API_KEY: z.string().min(10),
});
const env = envSchema.parse(process.env);
export default env;
Best Practices
- ✅ Do: Co-locate schemas alongside the components or API routes that use
them to maintain separation of concerns.
- ✅ Do: Use
z.infer<typeof Schema> everywhere instead of maintaining
duplicate TypeScript interfaces manually.
- ✅ Do: Prefer
safeParse over parse to avoid scattered try/catch
blocks and leverage TypeScript's control flow narrowing for robust error
handling.
- ✅ Do: Use
z.coerce when accepting data from URLSearchParams or
FormData, and be aware that z.coerce.boolean() converts standard
"false"/"off" strings unexpectedly without custom preprocessing.
- ✅ Do: Use
.flatten() or .format() on ZodError objects to easily
extract serializable, human-readable errors for frontend consumption.
- ❌ Don't: Rely exclusively on
.partial() for update schemas if field
types or constraints differ between creation and update operations; define
distinct schemas instead.
- ❌ Don't: Forget to pass the
path option in .refine() or
.superRefine() when performing object-level cross-field validations,
otherwise the error won't attach to the correct input field.
Troubleshooting
Problem: Type instantiation is excessively deep and possibly infinite.
Solution: This occurs with extreme schema recursion (e.g. deeply nested
self-referential schemas). Use z.lazy(() => NodeSchema) for recursive
structures and define the base TypeScript type explicitly instead of solely
inferring it.
Problem: Empty strings pass validation when using .optional().
Solution: .optional() permits undefined, not empty strings. If an empty
string means "no value," use .or(z.literal("")) or preprocess it:
z.string().transform(v => v === "" ? undefined : v).optional().
Limitations
- Use this skill only when the task clearly matches the scope described above.
- Do not treat the output as a substitute for environment-specific validation,
testing, or expert review.
- Stop and ask for clarification if required inputs, permissions, safety
boundaries, or success criteria are missing.