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 keysArray 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.