Back to Blog
ZodValidationTypeScriptForms

Zod Schema Validation Patterns

Master runtime validation with Zod. From basic schemas to transformations to integration with React Hook Form.

B
Bootspring Team
Engineering
January 27, 2022
6 min read

Zod provides TypeScript-first schema validation. Here's how to use it for forms, APIs, and data transformation.

Basic Schemas#

1import { z } from 'zod'; 2 3// Primitive types 4const stringSchema = z.string(); 5const numberSchema = z.number(); 6const booleanSchema = z.boolean(); 7const dateSchema = z.date(); 8 9// With constraints 10const emailSchema = z.string().email('Invalid email address'); 11const ageSchema = z.number().min(0).max(120); 12const urlSchema = z.string().url(); 13const uuidSchema = z.string().uuid(); 14 15// Optional and nullable 16const optionalString = z.string().optional(); // string | undefined 17const nullableString = z.string().nullable(); // string | null 18const nullishString = z.string().nullish(); // string | null | undefined 19 20// Default values 21const statusSchema = z.string().default('pending'); 22 23// Literals and enums 24const roleSchema = z.enum(['admin', 'user', 'guest']); 25const statusLiteral = z.literal('active'); 26 27// Parse and validate 28const result = emailSchema.parse('user@example.com'); // Returns string or throws 29const safeResult = emailSchema.safeParse('invalid'); // Returns { success, data/error }

Object Schemas#

1// Object schema 2const userSchema = z.object({ 3 id: z.string().uuid(), 4 email: z.string().email(), 5 name: z.string().min(2).max(100), 6 age: z.number().int().positive().optional(), 7 role: z.enum(['admin', 'user']), 8 createdAt: z.date(), 9}); 10 11// Infer TypeScript type 12type User = z.infer<typeof userSchema>; 13 14// Partial - all fields optional 15const partialUserSchema = userSchema.partial(); 16 17// Pick specific fields 18const userCredentials = userSchema.pick({ email: true, name: true }); 19 20// Omit fields 21const userWithoutId = userSchema.omit({ id: true }); 22 23// Extend schema 24const adminUserSchema = userSchema.extend({ 25 permissions: z.array(z.string()), 26}); 27 28// Merge schemas 29const addressSchema = z.object({ 30 street: z.string(), 31 city: z.string(), 32 country: z.string(), 33}); 34 35const userWithAddressSchema = userSchema.merge(addressSchema); 36 37// Strict and passthrough 38const strictSchema = z.object({ name: z.string() }).strict(); // Fails on extra keys 39const passthroughSchema = z.object({ name: z.string() }).passthrough(); // Allows extra keys

Array and Tuple Schemas#

1// Arrays 2const tagsSchema = z.array(z.string()); 3const itemsSchema = z.array(z.object({ 4 id: z.string(), 5 quantity: z.number().positive(), 6})); 7 8// Array constraints 9const limitedArray = z.array(z.string()).min(1).max(10); 10const nonEmptyArray = z.array(z.string()).nonempty(); 11 12// Tuples 13const coordinatesSchema = z.tuple([z.number(), z.number()]); 14const mixedTupleSchema = z.tuple([z.string(), z.number(), z.boolean()]); 15 16// Rest elements 17const argsSchema = z.tuple([z.string()]).rest(z.number()); 18// [string, ...number[]]

Union and Discriminated Union#

1// Union types 2const stringOrNumber = z.union([z.string(), z.number()]); 3// Shorthand 4const stringOrNumberShort = z.string().or(z.number()); 5 6// Discriminated union (more efficient) 7const eventSchema = z.discriminatedUnion('type', [ 8 z.object({ 9 type: z.literal('click'), 10 x: z.number(), 11 y: z.number(), 12 }), 13 z.object({ 14 type: z.literal('keypress'), 15 key: z.string(), 16 }), 17 z.object({ 18 type: z.literal('scroll'), 19 direction: z.enum(['up', 'down']), 20 }), 21]); 22 23type Event = z.infer<typeof eventSchema>; 24 25// API response pattern 26const apiResponseSchema = z.discriminatedUnion('status', [ 27 z.object({ 28 status: z.literal('success'), 29 data: z.unknown(), 30 }), 31 z.object({ 32 status: z.literal('error'), 33 error: z.string(), 34 code: z.number(), 35 }), 36]);

Transformations#

1// Transform during parsing 2const lowercaseEmail = z.string().email().transform((val) => val.toLowerCase()); 3 4// Parse and transform 5const numberFromString = z.string().transform((val) => parseInt(val, 10)); 6 7// Coercion (built-in transforms) 8const coercedNumber = z.coerce.number(); // Converts to number 9const coercedString = z.coerce.string(); // Converts to string 10const coercedDate = z.coerce.date(); // Converts to Date 11 12// Complex transformation 13const userInputSchema = z.object({ 14 email: z.string().email().transform((s) => s.toLowerCase().trim()), 15 birthDate: z.string().transform((s) => new Date(s)), 16 tags: z.string().transform((s) => s.split(',').map((t) => t.trim())), 17}); 18 19// Pre-process input before validation 20const preprocessedSchema = z.preprocess( 21 (val) => (typeof val === 'string' ? JSON.parse(val) : val), 22 z.object({ name: z.string() }) 23); 24 25// Refine with custom validation 26const passwordSchema = z.string() 27 .min(8, 'Password must be at least 8 characters') 28 .refine((val) => /[A-Z]/.test(val), 'Must contain uppercase') 29 .refine((val) => /[0-9]/.test(val), 'Must contain number');

Custom Validation with Refine#

1// Simple refine 2const evenNumber = z.number().refine((n) => n % 2 === 0, { 3 message: 'Must be an even number', 4}); 5 6// Async refine 7const uniqueEmail = z.string().email().refine( 8 async (email) => { 9 const exists = await checkEmailExists(email); 10 return !exists; 11 }, 12 { message: 'Email already in use' } 13); 14 15// Super refine for complex validation 16const formSchema = z.object({ 17 password: z.string().min(8), 18 confirmPassword: z.string(), 19}).superRefine((data, ctx) => { 20 if (data.password !== data.confirmPassword) { 21 ctx.addIssue({ 22 code: z.ZodIssueCode.custom, 23 message: 'Passwords do not match', 24 path: ['confirmPassword'], 25 }); 26 } 27}); 28 29// Conditional validation 30const conditionalSchema = z.object({ 31 hasDiscount: z.boolean(), 32 discountCode: z.string().optional(), 33}).refine( 34 (data) => { 35 if (data.hasDiscount && !data.discountCode) { 36 return false; 37 } 38 return true; 39 }, 40 { 41 message: 'Discount code is required when hasDiscount is true', 42 path: ['discountCode'], 43 } 44);

React Hook Form Integration#

1import { useForm } from 'react-hook-form'; 2import { zodResolver } from '@hookform/resolvers/zod'; 3import { z } from 'zod'; 4 5const signupSchema = z.object({ 6 email: z.string().email('Invalid email'), 7 password: z.string().min(8, 'Password must be at least 8 characters'), 8 confirmPassword: z.string(), 9 acceptTerms: z.literal(true, { 10 errorMap: () => ({ message: 'You must accept the terms' }), 11 }), 12}).refine((data) => data.password === data.confirmPassword, { 13 message: 'Passwords do not match', 14 path: ['confirmPassword'], 15}); 16 17type SignupForm = z.infer<typeof signupSchema>; 18 19function SignupForm() { 20 const { 21 register, 22 handleSubmit, 23 formState: { errors }, 24 } = useForm<SignupForm>({ 25 resolver: zodResolver(signupSchema), 26 }); 27 28 const onSubmit = (data: SignupForm) => { 29 console.log(data); 30 }; 31 32 return ( 33 <form onSubmit={handleSubmit(onSubmit)}> 34 <input {...register('email')} type="email" /> 35 {errors.email && <span>{errors.email.message}</span>} 36 37 <input {...register('password')} type="password" /> 38 {errors.password && <span>{errors.password.message}</span>} 39 40 <input {...register('confirmPassword')} type="password" /> 41 {errors.confirmPassword && <span>{errors.confirmPassword.message}</span>} 42 43 <input {...register('acceptTerms')} type="checkbox" /> 44 {errors.acceptTerms && <span>{errors.acceptTerms.message}</span>} 45 46 <button type="submit">Sign Up</button> 47 </form> 48 ); 49}

API Validation#

1// Express middleware 2import { Request, Response, NextFunction } from 'express'; 3 4function validate<T>(schema: z.ZodType<T>) { 5 return (req: Request, res: Response, next: NextFunction) => { 6 const result = schema.safeParse(req.body); 7 8 if (!result.success) { 9 return res.status(400).json({ 10 error: 'Validation failed', 11 issues: result.error.issues, 12 }); 13 } 14 15 req.body = result.data; 16 next(); 17 }; 18} 19 20const createUserSchema = z.object({ 21 email: z.string().email(), 22 name: z.string().min(2), 23 role: z.enum(['admin', 'user']).default('user'), 24}); 25 26app.post('/users', validate(createUserSchema), (req, res) => { 27 // req.body is typed and validated 28 const user = req.body; 29 // ... 30}); 31 32// tRPC integration 33import { initTRPC } from '@trpc/server'; 34 35const t = initTRPC.create(); 36 37const appRouter = t.router({ 38 createUser: t.procedure 39 .input(createUserSchema) 40 .mutation(({ input }) => { 41 // input is typed 42 return createUser(input); 43 }), 44});

Best Practices#

Schemas: ✓ Define schemas close to usage ✓ Reuse common schemas ✓ Use discriminated unions for tagged types ✓ Export inferred types Validation: ✓ Provide helpful error messages ✓ Use coercion for external input ✓ Validate at system boundaries ✓ Handle async validation carefully Performance: ✓ Parse once, use types after ✓ Avoid excessive refinements ✓ Cache compiled schemas ✓ Use strict mode appropriately

Conclusion#

Zod bridges runtime validation and TypeScript types. Use object schemas for structured data, discriminated unions for tagged types, and transformations for data cleaning. Integration with React Hook Form and tRPC makes Zod essential for full-stack TypeScript applications.

Share this article

Help spread the word about Bootspring