Zod Reference: Schemas, safeParse, Transforms, React Hook Form & Advanced Patterns
Zod is a TypeScript-first schema validation library. Define a schema once and get both runtime validation and compile-time TypeScript types inferred from it — no separate type definitions needed. It’s the standard in Next.js, tRPC, and React Hook Form projects. The key patterns: use z.infer to derive types, safeParse instead of parse in production (doesn’t throw), and .transform() to coerce data at validation time.
1. Basic Types & Schema Composition
String, number, object, array, union, and TypeScript type inference
import { z } from "zod";
// Primitive types:
const nameSchema = z.string().min(1).max(100).trim();
const ageSchema = z.number().int().min(0).max(150);
const emailSchema = z.string().email();
const urlSchema = z.string().url();
const uuidSchema = z.string().uuid();
const isoDateSchema = z.string().datetime(); // ISO 8601 datetime
// Object schema:
const userSchema = z.object({
id: z.number().int().positive(),
name: z.string().min(1).max(100).trim(),
email: z.string().email().toLowerCase(), // .toLowerCase() = transform
age: z.number().int().min(0).optional(), // undefined is allowed
website: z.string().url().nullable(), // null is allowed
tags: z.array(z.string()).default([]),
createdAt: z.coerce.date(), // "2026-03-14" → Date object
});
// Infer TypeScript type from schema (no separate interface needed!):
type User = z.infer;
// User = { id: number; name: string; email: string; age?: number; ... }
// Union and discriminated union:
const shapeSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("circle"), radius: z.number() }),
z.object({ type: z.literal("rect"), width: z.number(), height: z.number() }),
]);
// Enum:
const roleSchema = z.enum(["admin", "user", "moderator"]);
type Role = z.infer; // "admin" | "user" | "moderator"
2. parse vs safeParse & Error Handling
safeParse for production, ZodError structure, and formatting errors for APIs
// parse: throws ZodError on failure (use in trusted contexts):
const user = userSchema.parse({ id: 1, name: "Alice", email: "alice@example.com" });
// safeParse: returns { success, data } or { success, error } — never throws:
const result = userSchema.safeParse(rawInput);
if (!result.success) {
// result.error is a ZodError:
console.log(result.error.issues);
// [{ path: ["email"], message: "Invalid email", code: "invalid_string" }]
// Format for API response:
const errors = result.error.flatten().fieldErrors;
// { email: ["Invalid email"], name: ["String must contain at least 1 character(s)"] }
return Response.json({ errors }, { status: 422 });
}
const user = result.data; // fully typed User
// parseAsync / safeParseAsync (when you have async refinements):
const result = await userSchema.safeParseAsync(rawInput);
// Format errors for different consumers:
result.error.format();
// { _errors: [], email: { _errors: ["Invalid email"] } }
result.error.flatten();
// { formErrors: [], fieldErrors: { email: ["Invalid email"] } }
result.error.issues.map(i => ({ path: i.path.join("."), message: i.message }));
3. Transforms, Refinements & Custom Validators
Transform input data, refine with custom logic, and cross-field validation with superRefine
import { z } from "zod";
// .transform(): coerce/modify value at parse time:
const slugSchema = z.string()
.toLowerCase()
.trim()
.transform(val => val.replace(/\s+/g, "-")); // "Hello World" → "hello-world"
// .refine(): custom validation with a predicate:
const passwordSchema = z.string()
.min(8, "At least 8 characters")
.refine(val => /[A-Z]/.test(val), "Must contain uppercase")
.refine(val => /[0-9]/.test(val), "Must contain a number");
// Cross-field validation with superRefine:
const signupSchema = z.object({
password: z.string().min(8),
confirm: z.string(),
}).superRefine((data, ctx) => {
if (data.password !== data.confirm) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Passwords do not match",
path: ["confirm"], // which field shows the error
});
}
});
// pipe: chain schemas (validate then transform):
const numericStringSchema = z.string()
.regex(/^\d+$/, "Must be numeric")
.transform(Number) // parse as number after validation
.pipe(z.number().int().positive());
// preprocesss: transform BEFORE validation (for coercion):
const boolSchema = z.preprocess(
(val) => (val === "true" ? true : val === "false" ? false : val),
z.boolean()
);
boolSchema.parse("true"); // true (string coerced to boolean)
4. Forms — React Hook Form + Zod
zodResolver integration, useForm types inferred from schema, and server-side validation
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"),
password: z.string().min(8, "At least 8 characters"),
});
type LoginInput = z.infer;
function LoginForm() {
const { register, handleSubmit, formState: { errors } } = useForm({
resolver: zodResolver(loginSchema),
});
const onSubmit = (data: LoginInput) => {
// data is fully typed and validated
console.log(data.email); // TypeScript knows this is string
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("email")} />
{errors.email && <p>{errors.email.message}</p>}
<input {...register("password")} type="password" />
{errors.password && <p>{errors.password.message}</p>}
<button type="submit">Login</button>
</form>
);
}
// Server-side: same schema for both client + server validation:
// app/api/login/route.ts (Next.js):
export async function POST(req: Request) {
const body = await req.json();
const result = loginSchema.safeParse(body);
if (!result.success) {
return Response.json({ errors: result.error.flatten().fieldErrors }, { status: 422 });
}
// result.data.email is typed
}
5. Advanced — Merging, Extending, Partial & Coerce
Reuse schemas with .extend()/.merge()/.pick()/.omit()/.partial(), and z.coerce for URL/form params
// .extend(): add fields to existing schema (non-destructive):
const createUserSchema = z.object({ name: z.string(), email: z.string().email() });
const updateUserSchema = createUserSchema.extend({ id: z.number().int().positive() });
// .omit() / .pick(): subset of fields:
const publicUserSchema = createUserSchema.omit({ password: true });
const loginSchema = createUserSchema.pick({ email: true, password: true });
// .partial(): make all fields optional (useful for PATCH endpoints):
const patchUserSchema = updateUserSchema.partial().required({ id: true });
// All fields optional except id
// .merge(): combine two object schemas:
const withTimestamps = z.object({
createdAt: z.coerce.date(),
updatedAt: z.coerce.date(),
});
const fullUserSchema = createUserSchema.merge(withTimestamps);
// z.coerce: coerce before validating (great for URL params / form data which are strings):
const querySchema = z.object({
page: z.coerce.number().int().min(1).default(1), // "42" → 42
limit: z.coerce.number().int().min(1).max(100).default(20),
sort: z.enum(["asc", "desc"]).default("desc"),
});
// URL search params are all strings — z.coerce handles the conversion:
const params = querySchema.parse({
page: searchParams.get("page"), // "3" → 3
limit: searchParams.get("limit"), // null → default 20
});
// Readonly schemas (useful for config):
const configSchema = z.object({ apiKey: z.string(), timeout: z.number() }).readonly();
type Config = z.infer<typeof configSchema>; // { readonly apiKey: string; ... }
Track TypeScript and Node.js releases at ReleaseRun. Related: TypeScript Reference | Next.js App Router Reference | Express.js Reference | TypeScript EOL Tracker
🔍 Free tool: npm Package Health Checker — check Zod and related TypeScript validation packages for EOL status and known CVEs.
Founded
2023 in London, UK
Contact
hello@releaserun.com