--- name: zod-patterns description: | Zod schema validation, type-safe development, and strict TypeScript patterns. When user works with Zod, validates data, creates schemas, handles form validation, mentions z.object/z.string patterns, needs runtime validation, type-safe code, or strict TypeScript configuration. --- # Zod Patterns Agent ## What's New in Zod 4 (2025) - **Performance**: `z.array()` 7.4x faster, `z.object()` 6.5x faster (compared to Zod v3) - **Zod Mini**: Tree-shakable functional API variant - **Metadata API**: Attach custom metadata to schemas - **JSON Schema**: Built-in JSON Schema generation - **Locales**: Built-in i18n support for error messages - **`z.looseObject()`**: Replaces `.passthrough()` - **`z.strictObject()`**: Replaces `.strict()` ## Installation ```bash # Standard Zod v4 (recommended) bun add zod # Minimal bundle for tree-shaking (1.9KB gzipped) bun add @zod/mini ``` Requires TypeScript 5.5+ with `"strict": true` in tsconfig. ### Using @zod/mini ```typescript // Tree-shakable imports (only includes what you use) import { z } from "@zod/mini"; // Same API as full Zod const UserSchema = z.object({ id: z.string(), email: z.string().email(), }); // Results in significantly smaller bundles // Full Zod: ~5KB | @zod/mini: ~1.9KB (tree-shaken) ``` For library authors targeting v4: `import { z } from "zod/v4/core";` ## Basic Usage ### Defining and Parsing ```typescript import { z } from "zod"; // Define a schema const UserSchema = z.object({ name: z.string(), email: z.string().email(), age: z.number().int().positive(), }); // Parse data (throws on invalid) const user = UserSchema.parse({ name: "Alice", email: "alice@example.com", age: 25, }); // Safe parse (returns result object) const result = UserSchema.safeParse(data); if (result.success) { console.log(result.data); // typed as User } else { console.log(result.error.issues); // validation errors } ``` ### Type Inference ```typescript // Infer TypeScript type from schema type User = z.infer; // { name: string; email: string; age: number } // For schemas with transforms - input vs output types type UserInput = z.input; type UserOutput = z.output; ``` ## Primitive Types ### Strings ```typescript z.string(); // any string z.string().min(1); // non-empty z.string().max(100); // max length z.string().length(5); // exact length z.string().email(); // email format z.string().url(); // URL format z.string().uuid(); // UUID format z.string().cuid(); // CUID format z.string().cuid2(); // CUID2 format z.string().ulid(); // ULID format z.string().regex(/^[a-z]+$/); // custom regex z.string().includes("@"); // contains substring z.string().startsWith("http"); // starts with z.string().endsWith(".com"); // ends with z.string().datetime(); // ISO datetime z.string().date(); // ISO date z.string().time(); // ISO time z.string().ip(); // IP address z.string().trim(); // trim whitespace (transform) z.string().toLowerCase(); // lowercase (transform) z.string().toUpperCase(); // uppercase (transform) ``` ### Numbers ```typescript z.number(); // any number z.number().int(); // integer only z.number().positive(); // > 0 z.number().nonnegative(); // >= 0 z.number().negative(); // < 0 z.number().nonpositive(); // <= 0 z.number().min(5); // >= 5 z.number().max(100); // <= 100 z.number().gt(5); // > 5 z.number().gte(5); // >= 5 (alias for min) z.number().lt(100); // < 100 z.number().lte(100); // <= 100 (alias for max) z.number().multipleOf(5); // divisible by 5 z.number().finite(); // not Infinity z.number().safe(); // within safe integer range ``` ### Other Primitives ```typescript z.boolean(); // true or false z.bigint(); // BigInt values z.date(); // Date objects z.undefined(); // undefined only z.null(); // null only z.void(); // undefined (for function returns) z.any(); // bypass validation z.unknown(); // any, but type-safe usage z.never(); // always fails ``` ## Coercion Automatically convert input types before validation: ```typescript // String coercion z.coerce.string(); // String(input) z.coerce.number(); // Number(input) z.coerce.boolean(); // Boolean(input) z.coerce.bigint(); // BigInt(input) z.coerce.date(); // new Date(input) // With validation const ageSchema = z.coerce.number().int().positive(); ageSchema.parse("25"); // 25 ageSchema.parse("abc"); // throws - NaN is not positive // Common pitfall: empty string becomes 0 z.coerce.number().parse(""); // 0 (might not be desired) // Fix: preprocess to handle empty strings const safeNumber = z.preprocess( (val) => (val === "" ? undefined : val), z.coerce.number(), ); ``` ## Objects ### Basic Objects ```typescript const PersonSchema = z.object({ name: z.string(), age: z.number(), email: z.string().email().optional(), }); // By default, unknown keys are stripped PersonSchema.parse({ name: "Bob", age: 30, extra: "ignored" }); // { name: "Bob", age: 30 } ``` ### Object Modes (Zod 4) ```typescript // Standard - strips unknown keys z.object({ name: z.string() }); // Loose - passes through unknown keys z.looseObject({ name: z.string() }); // Strict - rejects unknown keys z.strictObject({ name: z.string() }); ``` ### Object Manipulation ```typescript const UserSchema = z.object({ id: z.string().uuid(), name: z.string(), email: z.string().email(), password: z.string(), createdAt: z.date(), }); // Pick specific fields const PublicUser = UserSchema.pick({ id: true, name: true, email: true }); // Omit sensitive fields const SafeUser = UserSchema.omit({ password: true }); // Make all fields optional const PartialUser = UserSchema.partial(); // Make specific fields optional const UpdateUser = UserSchema.partial({ name: true, email: true }); // Make all fields required const RequiredUser = UserSchema.required(); // Extend with additional fields const AdminSchema = UserSchema.extend({ role: z.literal("admin"), permissions: z.array(z.string()), }); // Merge schemas (Zod 4 - use extend instead) const Combined = BaseSchema.extend(ExtraSchema.shape); ``` ### Nested Objects ```typescript const AddressSchema = z.object({ street: z.string(), city: z.string(), country: z.string(), zip: z.string().optional(), }); const CompanySchema = z.object({ name: z.string(), address: AddressSchema, employees: z.array( z.object({ name: z.string(), department: z.string(), }), ), }); ``` ## Arrays and Collections ### Arrays ```typescript z.array(z.string()); // string[] z.array(z.string()).min(1); // at least 1 element z.array(z.string()).max(10); // at most 10 elements z.array(z.string()).length(5); // exactly 5 elements z.array(z.string()).nonempty(); // same as .min(1) // Access element schema const arr = z.array(z.string()); arr.element; // z.string() ``` ### Tuples ```typescript // Fixed-length array with specific types const PointSchema = z.tuple([z.number(), z.number()]); type Point = z.infer; // [number, number] // With rest elements const ArgsSchema = z.tuple([z.string(), z.number()]).rest(z.boolean()); // [string, number, ...boolean[]] ``` ### Records and Maps ```typescript // Record z.record(z.string(), z.number()); // { [key: string]: number } z.record(z.number()); // shorthand for string keys // Map z.map(z.string(), z.number()); // Map // Set z.set(z.string()); // Set z.set(z.number()).min(1).max(10); // with size constraints ``` ## Unions and Enums ### Unions ```typescript // Basic union const StringOrNumber = z.union([z.string(), z.number()]); type SN = z.infer; // string | number // Shorthand const StringOrNumber2 = z.string().or(z.number()); ``` ### Discriminated Unions ```typescript // More efficient parsing with discriminator key const ResultSchema = z.discriminatedUnion("status", [ z.object({ status: z.literal("success"), data: z.object({ id: z.string() }), }), z.object({ status: z.literal("error"), message: z.string(), }), ]); type Result = z.infer; // { status: "success"; data: { id: string } } | { status: "error"; message: string } // TypeScript narrows based on discriminator const result = ResultSchema.parse(data); if (result.status === "success") { console.log(result.data.id); // typed correctly } ``` ### Enums ```typescript // Zod enum (recommended) const StatusSchema = z.enum(["pending", "active", "inactive"]); type Status = z.infer; // "pending" | "active" | "inactive" // Access values StatusSchema.options; // ["pending", "active", "inactive"] StatusSchema.enum; // { pending: "pending", active: "active", ... } // Extract or exclude values StatusSchema.extract(["pending", "active"]); // only these StatusSchema.exclude(["inactive"]); // all except these // Native enum enum NativeStatus { Pending, Active, Inactive, } const NativeStatusSchema = z.nativeEnum(NativeStatus); ``` ### Literals ```typescript z.literal("hello"); // exactly "hello" z.literal(42); // exactly 42 z.literal(true); // exactly true z.null(); // exactly null z.undefined(); // exactly undefined ``` ## Optional, Nullable, Default ```typescript // Optional - allows undefined z.string().optional(); // string | undefined // Nullable - allows null z.string().nullable(); // string | null // Nullish - allows null or undefined z.string().nullish(); // string | null | undefined // Default values z.string().default("N/A"); // static default z.number().default(() => Math.random()); // dynamic default z.date().default(() => new Date()); // current date // Catch - use default on any parse error z.number().catch(0); // returns 0 on error z.string().catch((ctx) => `Error: ${ctx.error.message}`); // Unwrap optional/nullable const optStr = z.string().optional(); optStr.unwrap(); // z.string() ``` ## Refinements and Transforms ### Refinements (Custom Validation) ```typescript // Basic refine const PasswordSchema = z .string() .min(8, "Password must be at least 8 characters") .refine((val) => /[A-Z]/.test(val), "Password must contain uppercase letter") .refine((val) => /[0-9]/.test(val), "Password must contain number"); // With custom error path const FormSchema = z .object({ password: z.string(), confirmPassword: z.string(), }) .refine((data) => data.password === data.confirmPassword, { message: "Passwords don't match", path: ["confirmPassword"], // error appears on this field }); ``` ### SuperRefine (Multiple Issues) ```typescript const ComplexSchema = z.string().superRefine((val, ctx) => { if (val.length < 8) { ctx.addIssue({ code: z.ZodIssueCode.too_small, minimum: 8, type: "string", inclusive: true, message: "Too short", }); } if (!/[A-Z]/.test(val)) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Missing uppercase", }); } }); ``` ### Transforms ```typescript // Transform output type const TrimmedString = z.string().transform((val) => val.trim()); const NumberFromString = z .string() .transform((val) => parseInt(val, 10)) .pipe(z.number().int()); const DateFromISO = z .string() .datetime() .transform((val) => new Date(val)); // Transform with validation const PositiveFromString = z .string() .transform((val, ctx) => { const num = parseInt(val, 10); if (isNaN(num)) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Not a number", }); return z.NEVER; } return num; }) .pipe(z.number().positive()); ``` ### Preprocess ```typescript // Transform BEFORE parsing const TrimmedEmail = z.preprocess( (val) => (typeof val === "string" ? val.trim().toLowerCase() : val), z.string().email(), ); // Handle empty strings const OptionalNumber = z.preprocess( (val) => (val === "" ? undefined : val), z.coerce.number().optional(), ); ``` ### Pipe (Chaining Schemas) ```typescript // Chain multiple schemas const ParsedInt = z .string() .transform((val) => parseInt(val, 10)) .pipe(z.number().int().positive()); // With intermediate validation const EmailDomain = z .string() .email() .pipe(z.string().endsWith("@company.com")); ``` ## Error Handling ### Custom Error Messages ```typescript // Per-validator messages z.string().min(5, "Must be at least 5 characters"); z.string().min(5, { message: "Too short" }); // Schema-level errors z.string("Expected a string"); z.string({ error: (issue) => issue.input === undefined ? "Field is required" : "Must be a string", }); ``` ### Parsing Errors ```typescript const result = UserSchema.safeParse(input); if (!result.success) { // Formatted errors const formatted = result.error.format(); // { name: { _errors: ["Required"] }, email: { _errors: ["Invalid email"] } } // Flattened errors const flat = result.error.flatten(); // { formErrors: [], fieldErrors: { name: ["Required"], email: ["Invalid email"] } } // Raw issues result.error.issues.forEach((issue) => { console.log(issue.path, issue.message, issue.code); }); } ``` ### Error Maps ```typescript // Per-parse error customization schema.parse(data, { error: (issue) => `Custom error: ${issue.code}`, }); // Global configuration z.config({ customError: (issue) => { if (issue.code === "invalid_type") { return `Expected ${issue.expected}, got ${issue.received}`; } return issue.message; }, }); // Localization import { en } from "zod/v4/locales/en"; z.config(en()); ``` ## Advanced Patterns ### Branded Types ```typescript const UserId = z.string().uuid().brand<"UserId">(); const PostId = z.string().uuid().brand<"PostId">(); type UserId = z.infer; type PostId = z.infer; function getUser(id: UserId) { /* ... */ } const userId = UserId.parse("550e8400-e29b-41d4-a716-446655440000"); const postId = PostId.parse("550e8400-e29b-41d4-a716-446655440000"); getUser(userId); // OK getUser(postId); // Type error! ``` ### Recursive Types ```typescript interface Category { name: string; subcategories: Category[]; } const CategorySchema: z.ZodType = z.lazy(() => z.object({ name: z.string(), subcategories: z.array(CategorySchema), }), ); ``` ### JSON Type ```typescript // Generic JSON value const JsonValue: z.ZodType = z.lazy(() => z.union([ z.string(), z.number(), z.boolean(), z.null(), z.array(JsonValue), z.record(JsonValue), ]), ); ``` ### Function Schemas ```typescript const FnSchema = z.function().args(z.string(), z.number()).returns(z.boolean()); type Fn = z.infer; // (args_0: string, args_1: number) => boolean const validated = FnSchema.implement((name, age) => { return age > 18; }); ``` ### Async Validation ```typescript const UniqueEmailSchema = z .string() .email() .refine(async (email) => { const exists = await checkEmailExists(email); return !exists; }, "Email already taken"); // Must use async parse const result = await UniqueEmailSchema.safeParseAsync("test@example.com"); ``` ## Integration Patterns ### Form Validation ```typescript import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; const FormSchema = z.object({ email: z.string().email("Invalid email"), password: z.string().min(8, "At least 8 characters"), }); function MyForm() { const { register, handleSubmit, formState: { errors } } = useForm({ resolver: zodResolver(FormSchema), }); return (
{errors.email && {errors.email.message}} {/* ... */}
); } ``` ### API Validation ```typescript // Hono example import { zValidator } from "@hono/zod-validator"; const CreateUserSchema = z.object({ name: z.string().min(1), email: z.string().email(), }); app.post("/users", zValidator("json", CreateUserSchema), async (c) => { const data = c.req.valid("json"); // data is fully typed }); ``` ### Environment Variables ```typescript const EnvSchema = z.object({ DATABASE_URL: z.string().url(), PORT: z.coerce.number().default(3000), NODE_ENV: z .enum(["development", "production", "test"]) .default("development"), API_KEY: z.string().min(1), }); export const env = EnvSchema.parse(process.env); ``` ## Replacing Common Type Checks with Zod Prefer Zod validation over `typeof`, `instanceof`, or type guard functions. ### typeof / instanceof / Number checks → Zod ```typescript // ❌ Avoid typeof operator if (typeof value === "string") { return value.toUpperCase(); } // ✅ Prefer Zod validation const result = z.string().safeParse(value); if (result.success) { return result.data.toUpperCase(); } // ❌ Avoid instanceof if (err instanceof Error) { console.log(err.message); } // ✅ Prefer Zod const result = z.instanceof(Error).safeParse(err); if (result.success) { console.log(result.data.message); } // ❌ Avoid Number.isInteger if (Number.isInteger(value)) { return value * 2; } // ✅ Prefer Zod const result = z.number().int().safeParse(value); if (result.success) { return result.data * 2; } ``` ### Type predicates → Zod ```typescript // ❌ Avoid type guard functions function isUser(value: unknown): value is User { return typeof value === "object" && value !== null && "email" in value; } // ✅ Prefer Zod schema validation const UserSchema = z.object({ email: z.string().email(), name: z.string(), }); const result = UserSchema.safeParse(value); if (result.success) { const user = result.data; // Type-safe! } ``` ## Type Assertions Rules Only `as unknown` and `as const` are allowed — all other `as` casts are banned. ```typescript // ❌ Never do this - bypasses type safety const user = data as User; const id = value as string; // ✅ Cast to unknown first, then validate const data = response as unknown; const result = UserSchema.safeParse(data); if (result.success) { const user = result.data; } // ✅ Use 'as const' for literal types const STATUSES = ["pending", "approved", "rejected"] as const; type Status = (typeof STATUSES)[number]; ``` **Why no type assertions?** They bypass TypeScript's type checking, don't perform runtime validation, can cause runtime crashes, and hide bugs instead of catching them. Zod gives you both compile-time AND runtime safety. ## Strict TypeScript Configuration ### tsconfig.json Best Practices ```json { "compilerOptions": { "strict": true, "noUncheckedIndexedAccess": true, "noPropertyAccessFromIndexSignature": true, "exactOptionalPropertyTypes": true, "noImplicitOverride": true, "noFallthroughCasesInSwitch": true, "forceConsistentCasingInFileNames": true } } ``` ### Use 'type' Instead of 'interface' ```typescript // ✅ Prefer type type User = { id: string; email: string; }; type Admin = User & { permissions: string[]; }; // ❌ Avoid interface interface User { id: string; email: string; } ``` **Why?** Types are more flexible (unions, intersections) and consistent with Zod's inferred types. ## Best Practices Summary 1. **Use `safeParse()`** - avoid throwing in production code 2. **Infer types** - let Zod generate TypeScript types 3. **Compose schemas** - use `extend`, `pick`, `omit` for reuse 4. **Prefer coercion** over preprocess for type conversion 5. **Add custom messages** - improve user-facing errors 6. **Use discriminated unions** - for efficient tagged unions 7. **Brand sensitive types** - prevent accidental ID swapping 8. **Validate at boundaries** - API inputs, env vars, external data 9. **Use transforms sparingly** - keep schemas focused on validation 10. **Test edge cases** - empty strings, null, undefined ## When to Ask for Help - Complex recursive schema patterns - Performance optimization for high-throughput validation - Integration with specific ORMs or frameworks - Migration from other validation libraries - Custom error formatting requirements - Schema generation from external sources