Zod
Zod — schema declaration, parsing, type inference (z.infer), transforms, refinements, discriminated unions, error formatting, and form integration
Zod is a TypeScript-first schema validation library that derives static types from runtime schemas, eliminating the duplication between type definitions and validation logic. You define a schema once and get both a validator and a TypeScript type. Zod is designed to be composable — small schemas combine into larger ones through objects, unions, intersections, and pipes. Every schema is immutable; methods return new instances. Parse functions throw on failure (`parse`) or return a discriminated result (`safeParse`), giving you full control over error handling. ## Key Points - **Define schemas in a shared module** (e.g., `lib/schemas.ts`) and import on both client and server for consistent validation. - **Use `z.infer`** everywhere instead of manually duplicating types — this guarantees your types stay in sync with validation. - **Prefer `safeParse` over `parse`** in server code — catching `ZodError` with try/catch is fragile and mixes error types. `safeParse` gives a clean discriminated union. - **Use `z.coerce`** for form inputs — HTML forms always submit strings, so `z.coerce.number()` and `z.coerce.date()` are essential. - **Attach `path` to refinement errors** so they associate with the correct field in form integrations. - **Keep schemas granular** — compose large schemas from small, reusable pieces with `.extend()`, `.merge()`, `.pick()`, and `.omit()`. - **Use `.brand()`** for nominal types when you want to distinguish validated data from raw data at the type level. - **Validating and then casting** — if you call `parse()` and then use `as SomeType` on the result, you are throwing away the type safety Zod provides. Use `z.infer` instead. - **Putting transforms in refinements** — refinements should return booleans; use `.transform()` for data reshaping. Mixing concerns makes schemas hard to reason about. - **Ignoring `.flatten()` for form errors** — manually iterating `error.issues` to build field-error maps is unnecessary; `flatten()` does this for you. - **Creating circular schemas without `z.lazy`** — this will crash at module load time. Always wrap recursive references in `z.lazy()`. - **Duplicating schemas on client and server** — maintain one source of truth in a shared package or module, not two copies that drift apart. ## Quick Example ```typescript npm install zod ```
skilldb get forms-validation-skills/ZodFull skill: 289 linesZod
Core Philosophy
Zod is a TypeScript-first schema validation library that derives static types from runtime schemas, eliminating the duplication between type definitions and validation logic. You define a schema once and get both a validator and a TypeScript type. Zod is designed to be composable — small schemas combine into larger ones through objects, unions, intersections, and pipes. Every schema is immutable; methods return new instances. Parse functions throw on failure (parse) or return a discriminated result (safeParse), giving you full control over error handling.
Setup
npm install zod
Basic usage:
import { z } from "zod";
const UserSchema = z.object({
name: z.string().min(1, "Name is required"),
email: z.string().email("Invalid email address"),
age: z.number().int().min(13, "Must be at least 13"),
});
// Derive the TypeScript type from the schema
type User = z.infer<typeof UserSchema>;
// { name: string; email: string; age: number }
// Parse (throws ZodError on failure)
const user = UserSchema.parse({ name: "Alice", email: "a@b.com", age: 25 });
// SafeParse (returns { success, data } | { success, error })
const result = UserSchema.safeParse(unknownInput);
if (result.success) {
console.log(result.data); // fully typed User
} else {
console.error(result.error.flatten());
}
Key Techniques
Composing Schemas
const AddressSchema = z.object({
street: z.string().min(1),
city: z.string().min(1),
state: z.string().length(2),
zip: z.string().regex(/^\d{5}(-\d{4})?$/, "Invalid ZIP"),
});
const CompanySchema = z.object({
name: z.string(),
address: AddressSchema,
employees: z.array(UserSchema),
});
type Company = z.infer<typeof CompanySchema>;
Transforms
Transforms let you coerce or reshape data during parsing:
const FormDataSchema = z.object({
// HTML inputs always produce strings; coerce to number
price: z.string().transform((val) => {
const parsed = parseFloat(val);
if (isNaN(parsed)) throw new Error("Not a number");
return parsed;
}),
// Or use the built-in coerce helpers
quantity: z.coerce.number().int().positive(),
// Trim and lowercase
email: z.string().trim().toLowerCase().email(),
// Parse date strings
createdAt: z.coerce.date(),
});
type FormData = z.infer<typeof FormDataSchema>;
// { price: number; quantity: number; email: string; createdAt: Date }
Refinements
Add custom validation that cannot be expressed with built-in methods:
const PasswordSchema = z
.object({
password: z.string().min(8),
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Passwords do not match",
path: ["confirmPassword"], // attach error to confirmPassword field
});
// superRefine for multiple custom errors
const PasswordStrengthSchema = z.string().superRefine((val, ctx) => {
if (!/[A-Z]/.test(val)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Must contain an uppercase letter",
});
}
if (!/[0-9]/.test(val)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Must contain a digit",
});
}
});
Discriminated Unions
Model tagged unions cleanly for API responses or polymorphic forms:
const ShapeSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal("circle"),
radius: z.number().positive(),
}),
z.object({
type: z.literal("rectangle"),
width: z.number().positive(),
height: z.number().positive(),
}),
z.object({
type: z.literal("triangle"),
base: z.number().positive(),
height: z.number().positive(),
}),
]);
type Shape = z.infer<typeof ShapeSchema>;
// Discriminated unions give better error messages than plain unions —
// Zod identifies the discriminator value first, then validates only
// the matching branch.
Recursive and Lazy Schemas
interface Category {
name: string;
children: Category[];
}
const CategorySchema: z.ZodType<Category> = z.lazy(() =>
z.object({
name: z.string(),
children: z.array(CategorySchema),
})
);
Error Formatting
const result = UserSchema.safeParse({ name: "", email: "bad", age: 5 });
if (!result.success) {
// Flat list of messages
const flat = result.error.flatten();
// { formErrors: [], fieldErrors: { name: [...], email: [...], age: [...] } }
// Formatted nested structure
const formatted = result.error.format();
// { name: { _errors: ["..."] }, email: { _errors: ["..."] }, ... }
// Custom mapping
const mapped = result.error.issues.map((issue) => ({
field: issue.path.join("."),
message: issue.message,
}));
}
Integration with React Hook Form
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const ContactSchema = z.object({
name: z.string().min(1, "Required"),
email: z.string().email(),
message: z.string().min(10, "Too short").max(500, "Too long"),
});
type ContactData = z.infer<typeof ContactSchema>;
export function ContactForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<ContactData>({
resolver: zodResolver(ContactSchema),
});
return (
<form onSubmit={handleSubmit((data) => fetch("/api/contact", { method: "POST", body: JSON.stringify(data) }))}>
<input {...register("name")} />
{errors.name && <p>{errors.name.message}</p>}
<input {...register("email")} />
{errors.email && <p>{errors.email.message}</p>}
<textarea {...register("message")} />
{errors.message && <p>{errors.message.message}</p>}
<button type="submit">Send</button>
</form>
);
}
Server-Side API Validation
// Next.js Route Handler example
import { NextRequest, NextResponse } from "next/server";
const CreateOrderSchema = z.object({
productId: z.string().uuid(),
quantity: z.number().int().positive().max(100),
notes: z.string().max(500).optional(),
});
export async function POST(req: NextRequest) {
const body = await req.json();
const result = CreateOrderSchema.safeParse(body);
if (!result.success) {
return NextResponse.json(
{ errors: result.error.flatten().fieldErrors },
{ status: 400 }
);
}
const order = await db.orders.create({ data: result.data });
return NextResponse.json(order, { status: 201 });
}
Preprocess and Pipe
// preprocess runs before the schema, useful for raw input normalization
const NumericString = z.preprocess(
(val) => (typeof val === "string" ? parseInt(val, 10) : val),
z.number().int().positive()
);
// pipe chains schemas — output of first becomes input of second
const DateString = z.string().pipe(z.coerce.date());
Best Practices
- Define schemas in a shared module (e.g.,
lib/schemas.ts) and import on both client and server for consistent validation. - Use
z.infereverywhere instead of manually duplicating types — this guarantees your types stay in sync with validation. - Prefer
safeParseoverparsein server code — catchingZodErrorwith try/catch is fragile and mixes error types.safeParsegives a clean discriminated union. - Use
z.coercefor form inputs — HTML forms always submit strings, soz.coerce.number()andz.coerce.date()are essential. - Attach
pathto refinement errors so they associate with the correct field in form integrations. - Keep schemas granular — compose large schemas from small, reusable pieces with
.extend(),.merge(),.pick(), and.omit(). - Use
.brand()for nominal types when you want to distinguish validated data from raw data at the type level.
Anti-Patterns
- Validating and then casting — if you call
parse()and then useas SomeTypeon the result, you are throwing away the type safety Zod provides. Usez.inferinstead. - Putting transforms in refinements — refinements should return booleans; use
.transform()for data reshaping. Mixing concerns makes schemas hard to reason about. - Ignoring
.flatten()for form errors — manually iteratingerror.issuesto build field-error maps is unnecessary;flatten()does this for you. - Creating circular schemas without
z.lazy— this will crash at module load time. Always wrap recursive references inz.lazy(). - Using
z.any()orz.unknown()as an escape hatch — this defeats the purpose of schema validation. If you truly need to accept arbitrary data, usez.record()orz.passthrough()with known fields. - Duplicating schemas on client and server — maintain one source of truth in a shared package or module, not two copies that drift apart.
Install this skill directly: skilldb add forms-validation-skills
Related Skills
Conform
Conform — progressive enhancement forms, Server Actions, Zod integration, nested objects, arrays, intent buttons, and accessibility patterns
Formik
Formik — useFormik, Field, Form, validation with Yup, FieldArray, custom inputs, submission handling, and error display patterns
React Final Form
React Final Form — subscription-based form state management with fine-grained re-renders, field-level validation, decorator support, and zero dependencies
React Hook Form
React Hook Form — useForm, register, Controller, validation, nested fields, arrays (useFieldArray), DevTools, and performance optimization patterns
Valibot
Valibot — modular schema validation, tiny bundle size, parse/safeParse, pipe transformations, type inference, and comparison with Zod
Vest
Vest — unit-test-inspired form validation framework with suite/test syntax, async rules, group support, warning-level validations, and framework-agnostic design