zod-validation
SKILL.md
Zod Validation
This skill covers Zod v3+ patterns for building type-safe validation schemas for forms, APIs, and data parsing.
Core Concepts
Zod Benefits:
- TypeScript-first schema validation
- Runtime type checking
- Type inference from schemas
- Composable and reusable schemas
- Rich error messages
- Zero dependencies
Key Terms:
- Schema: Validation definition (z.object, z.string, etc.)
- Parse: Validate and return typed data
- SafeParse: Validate without throwing errors
- Transform: Convert data after validation
- Refine: Custom validation logic
Installation
npm install zod
Basic Types
Primitives
import { z } from "zod";
// String
const nameSchema = z.string();
const emailSchema = z.string().email();
const urlSchema = z.string().url();
const uuidSchema = z.string().uuid();
// Number
const ageSchema = z.number();
const priceSchema = z.number().positive();
const scoreSchema = z.number().min(0).max(100);
const integerSchema = z.number().int();
// Boolean
const agreedSchema = z.boolean();
// Date
const birthDateSchema = z.date();
const futureSchema = z.date().min(new Date());
// BigInt
const bigNumSchema = z.bigint();
// Symbol
const symSchema = z.symbol();
// Undefined, Null, Void
const undefinedSchema = z.undefined();
const nullSchema = z.null();
const voidSchema = z.void();
// Any, Unknown, Never
const anySchema = z.any(); // ⚠️ Avoid if possible
const unknownSchema = z.unknown();
const neverSchema = z.never();
String Validations
const schema = z.string()
.min(3, "Must be at least 3 characters")
.max(50, "Must be at most 50 characters")
.email("Invalid email address")
.url("Invalid URL")
.uuid("Invalid UUID")
.regex(/^[a-z0-9_]+$/, "Only lowercase letters, numbers, and underscores")
.startsWith("https://", "Must start with https://")
.endsWith(".com", "Must end with .com")
.includes("@", "Must include @")
.trim() // Remove whitespace
.toLowerCase() // Convert to lowercase
.toUpperCase(); // Convert to uppercase
// Custom error messages
const emailSchema = z.string().email({
message: "Please enter a valid email address",
});
// Length
const exactLength = z.string().length(5, "Must be exactly 5 characters");
// Date strings
const dateString = z.string().datetime(); // ISO 8601
const dateOnly = z.string().date(); // YYYY-MM-DD
const timeOnly = z.string().time(); // HH:mm:ss
// IP addresses
const ipv4 = z.string().ip({ version: "v4" });
const ipv6 = z.string().ip({ version: "v6" });
Number Validations
const schema = z.number()
.min(0, "Must be at least 0")
.max(100, "Must be at most 100")
.positive("Must be positive")
.negative("Must be negative")
.nonnegative("Must be 0 or greater")
.nonpositive("Must be 0 or less")
.int("Must be an integer")
.multipleOf(5, "Must be a multiple of 5")
.finite("Must be finite")
.safe("Must be a safe integer");
// Coerce string to number
const coercedNumber = z.coerce.number(); // "123" → 123
Object Schemas
Basic Objects
const userSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
name: z.string().min(1),
age: z.number().int().min(0).max(120),
verified: z.boolean(),
role: z.enum(["ADMIN", "USER", "GUEST"]),
createdAt: z.date(),
});
// Type inference
type User = z.infer<typeof userSchema>;
// {
// id: string;
// email: string;
// name: string;
// age: number;
// verified: boolean;
// role: "ADMIN" | "USER" | "GUEST";
// createdAt: Date;
// }
Optional and Nullable
const schema = z.object({
// Optional (can be undefined)
bio: z.string().optional(),
// Nullable (can be null)
avatar: z.string().url().nullable(),
// Nullable and optional (can be null or undefined)
middleName: z.string().nullable().optional(),
// With default value
role: z.string().default("USER"),
// Default from function
createdAt: z.date().default(() => new Date()),
});
Nested Objects
const addressSchema = z.object({
street: z.string(),
city: z.string(),
state: z.string().length(2),
zipCode: z.string().regex(/^\d{5}$/),
});
const userSchema = z.object({
name: z.string(),
email: z.string().email(),
address: addressSchema,
// Or inline
settings: z.object({
notifications: z.boolean(),
theme: z.enum(["light", "dark"]),
}),
});
Partial, Required, Pick, Omit
const userSchema = z.object({
id: z.string(),
email: z.string().email(),
name: z.string(),
bio: z.string().optional(),
});
// Make all fields optional
const partialUser = userSchema.partial();
type PartialUser = z.infer<typeof partialUser>;
// { id?: string; email?: string; name?: string; bio?: string }
// Make all fields required (remove optional)
const requiredUser = userSchema.required();
// Pick specific fields
const userCredentials = userSchema.pick({ email: true, password: true });
// Omit fields
const publicUser = userSchema.omit({ password: true });
// Make specific fields optional
const updateSchema = userSchema.partial({ bio: true });
Extend and Merge
const baseSchema = z.object({
id: z.string(),
createdAt: z.date(),
});
// Extend (adds fields)
const userSchema = baseSchema.extend({
email: z.string().email(),
name: z.string(),
});
// Merge (combines schemas)
const timestampSchema = z.object({
createdAt: z.date(),
updatedAt: z.date(),
});
const fullSchema = userSchema.merge(timestampSchema);
Arrays and Tuples
Arrays
// Array of strings
const tagsSchema = z.array(z.string());
// Array with constraints
const schema = z.array(z.string())
.min(1, "At least one tag required")
.max(5, "Maximum 5 tags allowed")
.nonempty("Array cannot be empty");
// Array of objects
const usersSchema = z.array(
z.object({
id: z.string(),
name: z.string(),
})
);
// Type inference
type Users = z.infer<typeof usersSchema>;
// Array<{ id: string; name: string }>
Tuples
// Fixed-length array with specific types
const coordinatesSchema = z.tuple([
z.number(), // latitude
z.number(), // longitude
]);
type Coordinates = z.infer<typeof coordinatesSchema>;
// [number, number]
// With rest parameters
const mixedSchema = z.tuple([
z.string(), // first element is string
z.number(), // second element is number
]).rest(z.boolean()); // rest are booleans
// ["hello", 42, true, false, true]
Enums and Literals
Enums
// Native enum
const schema = z.enum(["ADMIN", "USER", "GUEST"]);
type Role = z.infer<typeof schema>;
// "ADMIN" | "USER" | "GUEST"
// Access enum values
schema.enum.ADMIN; // "ADMIN"
schema.options; // ["ADMIN", "USER", "GUEST"]
// TypeScript enum
enum UserRole {
ADMIN = "ADMIN",
USER = "USER",
GUEST = "GUEST",
}
const roleSchema = z.nativeEnum(UserRole);
Literals
// Single literal value
const trueSchema = z.literal(true);
const adminSchema = z.literal("ADMIN");
const numberSchema = z.literal(42);
// Use with union for multiple values
const statusSchema = z.union([
z.literal("pending"),
z.literal("approved"),
z.literal("rejected"),
]);
// Or use enum
const statusEnum = z.enum(["pending", "approved", "rejected"]);
Unions and Discriminated Unions
Basic Unions
// String or number
const idSchema = z.union([z.string(), z.number()]);
// Null or string
const nullableString = z.union([z.string(), z.null()]);
// Or use .nullable()
const nullableString2 = z.string().nullable();
Discriminated Unions
// Better type inference for unions
const eventSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal("click"),
x: z.number(),
y: z.number(),
}),
z.object({
type: z.literal("keypress"),
key: z.string(),
}),
z.object({
type: z.literal("focus"),
element: z.string(),
}),
]);
type Event = z.infer<typeof eventSchema>;
// { type: "click"; x: number; y: number }
// | { type: "keypress"; key: string }
// | { type: "focus"; element: string }
// TypeScript knows which fields are available based on type
function handleEvent(event: Event) {
if (event.type === "click") {
console.log(event.x, event.y); // TypeScript knows x and y exist
} else if (event.type === "keypress") {
console.log(event.key); // TypeScript knows key exists
}
}
Custom Validation
Refine
// Single refinement
const passwordSchema = z
.string()
.min(8)
.refine(
(password) => /[A-Z]/.test(password),
{ message: "Password must contain an uppercase letter" }
)
.refine(
(password) => /[a-z]/.test(password),
{ message: "Password must contain a lowercase letter" }
)
.refine(
(password) => /[0-9]/.test(password),
{ message: "Password must contain a number" }
);
// Multi-field refinement
const signupSchema = z
.object({
password: z.string().min(8),
confirmPassword: z.string(),
})
.refine(
(data) => data.password === data.confirmPassword,
{
message: "Passwords don't match",
path: ["confirmPassword"], // Error attached to this field
}
);
// Async refinement
const emailSchema = z.string().email().refine(
async (email) => {
const user = await db.user.findUnique({ where: { email } });
return !user; // true if email is available
},
{ message: "Email already registered" }
);
Transform
// Transform after validation
const trimmedString = z.string().transform((str) => str.trim());
const numberFromString = z.string().transform((str) => parseInt(str, 10));
// Or use coerce
const coercedNumber = z.coerce.number(); // "123" → 123
// Complex transformation
const userSchema = z
.object({
email: z.string().email(),
name: z.string(),
})
.transform((data) => ({
...data,
email: data.email.toLowerCase(),
displayName: data.name.toUpperCase(),
}));
// Async transform
const uploadSchema = z
.instanceof(File)
.transform(async (file) => {
const url = await uploadToS3(file);
return { url, size: file.size };
});
Superrefine (Advanced)
const schema = z.object({
age: z.number(),
hasGuardian: z.boolean(),
guardianName: z.string().optional(),
}).superRefine((data, ctx) => {
if (data.age < 18 && !data.hasGuardian) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Guardian required for users under 18",
path: ["hasGuardian"],
});
}
if (data.hasGuardian && !data.guardianName) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Guardian name is required",
path: ["guardianName"],
});
}
});
Parsing and Validation
Parse (Throws on Error)
const userSchema = z.object({
email: z.string().email(),
age: z.number(),
});
try {
const user = userSchema.parse({
email: "user@example.com",
age: 25,
});
// user is typed as { email: string; age: number }
} catch (error) {
if (error instanceof z.ZodError) {
console.error(error.errors);
}
}
SafeParse (Returns Result Object)
const result = userSchema.safeParse({
email: "invalid",
age: "not a number",
});
if (result.success) {
const user = result.data;
// user is typed correctly
} else {
const errors = result.error.errors;
// [
// {
// code: "invalid_string",
// message: "Invalid email",
// path: ["email"],
// },
// {
// code: "invalid_type",
// message: "Expected number, received string",
// path: ["age"],
// }
// ]
}
Async Parsing
// For async transforms or refinements
const result = await schema.parseAsync(data);
const result = await schema.safeParseAsync(data);
Error Handling
Error Structure
try {
userSchema.parse(invalidData);
} catch (error) {
if (error instanceof z.ZodError) {
// error.errors is an array of issues
error.errors.forEach((issue) => {
console.log(issue.path); // ["email"]
console.log(issue.message); // "Invalid email"
console.log(issue.code); // "invalid_string"
});
// Formatted errors
const formatted = error.format();
// {
// email: { _errors: ["Invalid email"] },
// age: { _errors: ["Expected number, received string"] }
// }
// Flattened errors
const flattened = error.flatten();
// {
// formErrors: [],
// fieldErrors: {
// email: ["Invalid email"],
// age: ["Expected number, received string"]
// }
// }
}
}
Custom Error Messages
const schema = z.object({
email: z.string({
required_error: "Email is required",
invalid_type_error: "Email must be a string",
}).email("Please enter a valid email address"),
age: z.number({
required_error: "Age is required",
invalid_type_error: "Age must be a number",
}).min(18, "Must be at least 18 years old"),
});
// Global error map
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 };
});
Reusable Schemas
Schema Composition
// Base schemas
const emailSchema = z.string().email();
const passwordSchema = z.string().min(8).regex(/[A-Z]/).regex(/[0-9]/);
const timestampSchema = z.object({
createdAt: z.date().default(() => new Date()),
updatedAt: z.date().default(() => new Date()),
});
// Compose into larger schemas
const loginSchema = z.object({
email: emailSchema,
password: passwordSchema,
});
const userSchema = z.object({
id: z.string().uuid(),
email: emailSchema,
name: z.string(),
}).merge(timestampSchema);
Schema Factory
// Generic pagination schema
function paginatedSchema<T extends z.ZodTypeAny>(itemSchema: T) {
return z.object({
items: z.array(itemSchema),
total: z.number(),
page: z.number(),
pageSize: z.number(),
});
}
// Usage
const userSchema = z.object({ id: z.string(), name: z.string() });
const paginatedUsers = paginatedSchema(userSchema);
type PaginatedUsers = z.infer<typeof paginatedUsers>;
// {
// items: Array<{ id: string; name: string }>;
// total: number;
// page: number;
// pageSize: number;
// }
Shared Validation Schemas
// packages/core/src/schemas.ts
export const emailSchema = z.string().email();
export const phoneSchema = z.string().regex(/^\+?[1-9]\d{1,14}$/);
export const uuidSchema = z.string().uuid();
export const paginationSchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(10),
});
export const userCreateSchema = z.object({
email: emailSchema,
name: z.string().min(1).max(100),
phone: phoneSchema.optional(),
});
export const userUpdateSchema = userCreateSchema.partial();
// Use in multiple packages
// packages/functions/src/user/router.ts
import { userCreateSchema } from "@myapp/core";
export const userRouter = router({
create: protectedProcedure
.input(userCreateSchema)
.mutation(async ({ input }) => {
// input is typed from schema
}),
});
Advanced Patterns
Branded Types
// Create nominal types
const userId = z.string().uuid().brand("UserId");
const email = z.string().email().brand("Email");
type UserId = z.infer<typeof userId>; // string & { __brand: "UserId" }
type Email = z.infer<typeof email>; // string & { __brand: "Email" }
// Prevents mixing different string types
function getUser(id: UserId) { /* ... */ }
const validId = userId.parse("550e8400-e29b-41d4-a716-446655440000");
getUser(validId); // ✓ OK
const regularString = "550e8400-e29b-41d4-a716-446655440000";
getUser(regularString); // ✗ Type error
Lazy Schemas (Recursive)
// For recursive types
type Category = {
name: string;
subcategories: Category[];
};
const categorySchema: z.ZodType<Category> = z.lazy(() =>
z.object({
name: z.string(),
subcategories: z.array(categorySchema),
})
);
Catch (Fallback Values)
// Provide fallback on parse failure
const schema = z.string().catch("default value");
const result = schema.parse(123); // "default value"
// With function
const dateSchema = z.date().catch(() => new Date());
Pipe (Chain Schemas)
// Parse then transform
const schema = z.string().pipe(z.coerce.number());
const result = schema.parse("123"); // 123 (number)
// Multi-step validation
const trimmedEmail = z
.string()
.transform((s) => s.trim())
.pipe(z.string().email());
Integration Patterns
With Conform (Forms)
import { parseWithZod } from "@conform-to/zod";
const schema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const submission = parseWithZod(formData, { schema });
if (submission.status !== "success") {
return json({ submission: submission.reply() });
}
// submission.value is typed from schema
await login(submission.value);
return redirect("/dashboard");
}
With tRPC
import { router, publicProcedure } from "./trpc";
import { z } from "zod";
const userRouter = router({
create: publicProcedure
.input(
z.object({
email: z.string().email(),
name: z.string().min(1),
})
)
.output(
z.object({
id: z.string(),
email: z.string(),
name: z.string(),
})
)
.mutation(async ({ input }) => {
// input is typed
return await createUser(input);
}),
});
With Prisma
import { Prisma } from "@prisma/client";
// Validate before database operation
const userCreateSchema = z.object({
email: z.string().email(),
name: z.string(),
}) satisfies z.ZodType<Prisma.UserCreateInput>;
Best Practices
- Reuse schemas: Create shared schemas in core package
- Type from schemas: Use
z.inferinstead of duplicating types - Composable schemas: Build complex schemas from simple ones
- Async sparingly: Use async refinements only on server
- Custom errors: Provide helpful, user-friendly error messages
- Transform carefully: Keep transformations simple and predictable
- Test schemas: Unit test complex validation logic
- Use safeParse: Prefer safeParse over parse to avoid exceptions
- Branded types: Use for nominal typing when needed
- Document schemas: Add JSDoc comments to complex schemas
Common Gotcas
- Parse vs safeParse: parse throws, safeParse returns result
- Optional vs nullable: optional = undefined, nullable = null
- Async transforms: Must use parseAsync/safeParseAsync
- Transform order: Transforms run after validation
- Error paths: Use path in refine for specific field errors
- Coerce vs transform: coerce is simpler for type conversion
- Default values: Apply before validation
- Array validation: Min/max checks array length, not items
- Union types: Discriminated unions have better inference
- File validation: Use z.instanceof(File), not z.object()
Resources
Weekly Installs
9
Repository
tejovanthn/rasikalifeFirst Seen
9 days ago
Security Audits
Installed on
opencode9
gemini-cli9
github-copilot9
codex9
kimi-cli9
cursor9