Lesson 5.3: Data Validation with Zod
Duration: 75 minutes
Learning Objectives
By the end of this lesson, you will be able to:
- Understand why runtime validation is essential
- Define schemas using Zod's type system
- Validate data and handle validation errors
- Infer TypeScript types from Zod schemas
- Create complex schemas with nested objects and arrays
- Transform and refine data during validation
Introduction
TypeScript types exist only at compile time. When data comes from external sources (APIs, user input, files), TypeScript cannot verify it matches your types. This is where runtime validation comes in.
// TypeScript CANNOT catch this at runtime
interface User {
id: number;
name: string;
}
const apiResponse = await fetch('/api/user/1');
const user = (await apiResponse.json()) as User;
// What if API returns { id: "1", name: null }?
// TypeScript won't complain, but your code will break!
console.log(user.name.toUpperCase()); // Runtime error: Cannot read property 'toUpperCase' of null
Zod solves this by validating data at runtime and generating TypeScript types from schemas.
Getting Started with Zod
Installation
npm install zod
Basic Usage
import { z } from 'zod';
// Define a schema
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
});
// Infer TypeScript type from schema
type User = z.infer<typeof UserSchema>;
// Equivalent to: { id: number; name: string; email: string }
// Validate data
const validData = { id: 1, name: 'Alice', email: 'alice@example.com' };
const user = UserSchema.parse(validData); // Returns typed User
const invalidData = { id: '1', name: null, email: 'not-an-email' };
UserSchema.parse(invalidData); // Throws ZodError!
Primitive Types
Basic Primitives
import { z } from 'zod';
// String
const nameSchema = z.string();
nameSchema.parse('Alice'); // "Alice"
nameSchema.parse(123); // Throws error
// Number
const ageSchema = z.number();
ageSchema.parse(25); // 25
ageSchema.parse('25'); // Throws error
// Boolean
const activeSchema = z.boolean();
activeSchema.parse(true); // true
activeSchema.parse('yes'); // Throws error
// Null and Undefined
const nullSchema = z.null();
const undefinedSchema = z.undefined();
// Any (avoid when possible)
const anySchema = z.any();
// Unknown (safer than any)
const unknownSchema = z.unknown();
String Validations
const stringSchema = z
.string()
.min(1, 'Cannot be empty')
.max(100, 'Too long')
.email('Invalid email')
.url('Invalid URL')
.uuid('Invalid UUID')
.regex(/^[A-Z]/, 'Must start with uppercase')
.startsWith('Hello')
.endsWith('!')
.includes('world')
.trim()
.toLowerCase()
.toUpperCase();
// Email validation
const emailSchema = z.string().email();
emailSchema.parse('alice@example.com'); // Valid
emailSchema.parse('not-an-email'); // Throws
// URL validation
const urlSchema = z.string().url();
urlSchema.parse('https://example.com'); // Valid
urlSchema.parse('not-a-url'); // Throws
Number Validations
const numberSchema = z
.number()
.min(0, 'Must be positive')
.max(100, 'Must be 100 or less')
.int('Must be an integer')
.positive('Must be positive')
.negative('Must be negative')
.nonnegative('Must be >= 0')
.nonpositive('Must be <= 0')
.finite('Must be finite')
.multipleOf(5, 'Must be multiple of 5');
// Age validation
const ageSchema = z.number().int().min(0).max(150);
ageSchema.parse(25); // Valid
ageSchema.parse(-5); // Throws
ageSchema.parse(200); // Throws
// Price validation (allow decimals)
const priceSchema = z.number().nonnegative();
priceSchema.parse(99.99); // Valid
Literal Types
// Exact values
const statusSchema = z.literal('active');
statusSchema.parse('active'); // Valid
statusSchema.parse('inactive'); // Throws
// Literal union (enum-like)
const roleSchema = z.union([z.literal('admin'), z.literal('user'), z.literal('guest')]);
// Or use z.enum
const roleSchema2 = z.enum(['admin', 'user', 'guest']);
type Role = z.infer<typeof roleSchema2>; // "admin" | "user" | "guest"
Objects and Arrays
Object Schemas
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
age: z.number().optional(), // Optional field
role: z.enum(['admin', 'user']).default('user'), // Default value
});
type User = z.infer<typeof UserSchema>;
// {
// id: number;
// name: string;
// email: string;
// age?: number | undefined;
// role: "admin" | "user";
// }
// Validation
const user = UserSchema.parse({
id: 1,
name: 'Alice',
email: 'alice@example.com',
});
console.log(user.role); // "user" (default applied)
Nested Objects
const AddressSchema = z.object({
street: z.string(),
city: z.string(),
country: z.string(),
zipCode: z.string().optional(),
});
const PersonSchema = z.object({
name: z.string(),
address: AddressSchema,
workAddress: AddressSchema.optional(),
});
type Person = z.infer<typeof PersonSchema>;
const person = PersonSchema.parse({
name: 'Alice',
address: {
street: '123 Main St',
city: 'London',
country: 'UK',
},
});
Array Schemas
// Array of strings
const tagsSchema = z.array(z.string());
tagsSchema.parse(['a', 'b', 'c']); // Valid
tagsSchema.parse([1, 2, 3]); // Throws
// Array with constraints
const scoresSchema = z
.array(z.number())
.min(1, 'Need at least one score')
.max(10, 'Maximum 10 scores')
.nonempty('Cannot be empty');
// Array of objects
const UsersArraySchema = z.array(UserSchema);
const users = UsersArraySchema.parse([
{ id: 1, name: 'Alice', email: 'alice@example.com' },
{ id: 2, name: 'Bob', email: 'bob@example.com' },
]);
Tuples
// Fixed-length arrays with specific types per position
const coordinateSchema = z.tuple([z.number(), z.number()]);
coordinateSchema.parse([10, 20]); // Valid: [number, number]
coordinateSchema.parse([10, 20, 30]); // Throws (too many elements)
// Mixed types
const resultSchema = z.tuple([z.string(), z.number(), z.boolean()]);
resultSchema.parse(['success', 200, true]); // Valid
Optional, Nullable, and Defaults
Optional Fields
const schema = z.object({
required: z.string(),
optional: z.string().optional(), // string | undefined
});
schema.parse({ required: 'hello' }); // Valid
schema.parse({ required: 'hello', optional: 'world' }); // Valid
schema.parse({ required: 'hello', optional: undefined }); // Valid
Nullable Fields
const schema = z.object({
name: z.string().nullable(), // string | null
});
schema.parse({ name: 'Alice' }); // Valid
schema.parse({ name: null }); // Valid
schema.parse({ name: undefined }); // Throws!
Nullable and Optional
const schema = z.object({
// Can be string, null, or undefined (omitted)
nickname: z.string().nullable().optional(),
});
schema.parse({}); // Valid
schema.parse({ nickname: null }); // Valid
schema.parse({ nickname: 'Ali' }); // Valid
Default Values
const ConfigSchema = z.object({
host: z.string().default('localhost'),
port: z.number().default(3000),
debug: z.boolean().default(false),
});
const config = ConfigSchema.parse({});
console.log(config);
// { host: "localhost", port: 3000, debug: false }
const customConfig = ConfigSchema.parse({ port: 8080 });
console.log(customConfig);
// { host: "localhost", port: 8080, debug: false }
Union and Discriminated Unions
Union Types
// Value can be string OR number
const stringOrNumber = z.union([z.string(), z.number()]);
stringOrNumber.parse('hello'); // Valid
stringOrNumber.parse(42); // Valid
stringOrNumber.parse(true); // Throws
// Shorthand
const stringOrNumber2 = z.string().or(z.number());
Discriminated Unions
For objects that have a common "discriminator" field:
const DogSchema = z.object({
type: z.literal('dog'),
name: z.string(),
breed: z.string(),
});
const CatSchema = z.object({
type: z.literal('cat'),
name: z.string(),
indoor: z.boolean(),
});
const AnimalSchema = z.discriminatedUnion('type', [DogSchema, CatSchema]);
type Animal = z.infer<typeof AnimalSchema>;
// Usage
const dog = AnimalSchema.parse({
type: 'dog',
name: 'Rex',
breed: 'German Shepherd',
});
const cat = AnimalSchema.parse({
type: 'cat',
name: 'Whiskers',
indoor: true,
});
Parsing Methods
parse() - Throws on Error
const schema = z.string();
try {
const result = schema.parse(123); // Throws ZodError
} catch (error) {
if (error instanceof z.ZodError) {
console.log(error.errors);
}
}
safeParse() - Returns Result Object
const schema = z.string().email();
const result = schema.safeParse('not-an-email');
if (result.success) {
console.log('Valid:', result.data);
} else {
console.log('Errors:', result.error.errors);
}
parseAsync() - For Async Validation
const schema = z.string().refine(async (val) => {
// Async validation (e.g., check database)
return val !== 'taken';
}, 'Username is taken');
const result = await schema.parseAsync('newuser');
Error Handling
Accessing Error Details
const UserSchema = z.object({
name: z.string().min(1, 'Name required'),
email: z.string().email('Invalid email'),
age: z.number().min(0, 'Age must be positive'),
});
const result = UserSchema.safeParse({
name: '',
email: 'bad-email',
age: -5,
});
if (!result.success) {
console.log(result.error.errors);
// [
// { path: ["name"], message: "Name required", code: "too_small", ... },
// { path: ["email"], message: "Invalid email", code: "invalid_string", ... },
// { path: ["age"], message: "Age must be positive", code: "too_small", ... }
// ]
// Format errors by field
const formatted = result.error.format();
console.log(formatted.name?._errors); // ["Name required"]
console.log(formatted.email?._errors); // ["Invalid email"]
}
Custom Error Messages
const schema = z.object({
username: z
.string({
required_error: 'Username is required',
invalid_type_error: 'Username must be a string',
})
.min(3, { message: 'Username must be at least 3 characters' }),
age: z
.number({
required_error: 'Age is required',
})
.min(18, 'Must be 18 or older'),
});
Transformations
Transform Data During Validation
// String to number
const stringToNumber = z.string().transform((val) => parseInt(val, 10));
stringToNumber.parse('42'); // Returns number 42
// Transform object shape
const ApiUserSchema = z
.object({
user_id: z.number(),
user_name: z.string(),
email_address: z.string().email(),
})
.transform((user) => ({
id: user.user_id,
name: user.user_name,
email: user.email_address,
}));
type ApiUser = z.input<typeof ApiUserSchema>; // Input type (snake_case)
type User = z.output<typeof ApiUserSchema>; // Output type (camelCase)
const user = ApiUserSchema.parse({
user_id: 1,
user_name: 'Alice',
email_address: 'alice@example.com',
});
// { id: 1, name: "Alice", email: "alice@example.com" }
Coercion
// Coerce to string (calls String() on input)
const coercedString = z.coerce.string();
coercedString.parse(123); // "123"
coercedString.parse(true); // "true"
// Coerce to number (calls Number() on input)
const coercedNumber = z.coerce.number();
coercedNumber.parse('42'); // 42
coercedNumber.parse('3.14'); // 3.14
// Coerce to boolean
const coercedBoolean = z.coerce.boolean();
coercedBoolean.parse('true'); // true (any truthy string)
coercedBoolean.parse(''); // false (empty string is falsy)
coercedBoolean.parse(1); // true
// Coerce to date
const coercedDate = z.coerce.date();
coercedDate.parse('2024-03-15'); // Date object
coercedDate.parse(1710460800000); // Date from timestamp
Refinements
Custom Validation Rules
// Simple refinement
const passwordSchema = z
.string()
.min(8, 'Password must be at least 8 characters')
.refine((val) => /[A-Z]/.test(val), 'Password must contain an uppercase letter')
.refine((val) => /[0-9]/.test(val), 'Password must contain a number');
// Using superRefine for multiple errors
const formSchema = z
.object({
password: z.string(),
confirmPassword: z.string(),
})
.superRefine((data, ctx) => {
if (data.password !== data.confirmPassword) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Passwords do not match',
path: ['confirmPassword'],
});
}
});
Async Refinements
const usernameSchema = z
.string()
.min(3)
.refine(async (username) => {
// Simulate API call to check availability
const isAvailable = await checkUsernameAvailable(username);
return isAvailable;
}, 'Username is already taken');
// Must use parseAsync
const result = await usernameSchema.parseAsync('newuser');
Practical Patterns
API Response Validation
// Define schema for API response
const PostSchema = z.object({
id: z.number(),
userId: z.number(),
title: z.string(),
body: z.string(),
});
const PostsResponseSchema = z.array(PostSchema);
type Post = z.infer<typeof PostSchema>;
// Fetch with validation
async function fetchPosts(): Promise<Post[]> {
const response = await fetch('https://jsonplaceholder.typicode.com/posts');
const data = await response.json();
// Validate and return typed data
return PostsResponseSchema.parse(data);
}
// Safe version
async function fetchPostsSafe(): Promise<Post[] | null> {
const response = await fetch('https://jsonplaceholder.typicode.com/posts');
const data = await response.json();
const result = PostsResponseSchema.safeParse(data);
if (!result.success) {
console.error('Invalid API response:', result.error);
return null;
}
return result.data;
}
Form Validation
const RegistrationSchema = z
.object({
username: z
.string()
.min(3, 'Username must be at least 3 characters')
.max(20, 'Username must be at most 20 characters')
.regex(/^[a-zA-Z0-9_]+$/, 'Username can only contain letters, numbers, and underscores'),
email: z.string().email('Please enter a valid email'),
password: z
.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Password must contain an uppercase letter')
.regex(/[a-z]/, 'Password must contain a lowercase letter')
.regex(/[0-9]/, 'Password must contain a number'),
confirmPassword: z.string(),
acceptTerms: z.literal(true, {
message: 'You must accept the terms',
}),
})
.refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
});
type RegistrationForm = z.infer<typeof RegistrationSchema>;
function validateForm(
formData: unknown
): { valid: true; data: RegistrationForm } | { valid: false; errors: Record<string, string[]> } {
const result = RegistrationSchema.safeParse(formData);
if (result.success) {
return { valid: true, data: result.data };
}
// Convert errors to field-based format
const errors: Record<string, string[]> = {};
for (const error of result.error.errors) {
const field = error.path.join('.');
if (!errors[field]) {
errors[field] = [];
}
errors[field].push(error.message);
}
return { valid: false, errors };
}
Configuration Validation
const DatabaseConfigSchema = z.object({
host: z.string().default('localhost'),
port: z.number().int().min(1).max(65535).default(5432),
database: z.string(),
username: z.string(),
password: z.string(),
ssl: z.boolean().default(false),
poolSize: z.number().int().min(1).max(100).default(10),
});
const AppConfigSchema = z.object({
env: z.enum(['development', 'staging', 'production']),
port: z.number().int().default(3000),
database: DatabaseConfigSchema,
apiKeys: z.record(z.string()), // Record<string, string>
});
type AppConfig = z.infer<typeof AppConfigSchema>;
function loadConfig(raw: unknown): AppConfig {
return AppConfigSchema.parse(raw);
}
// Usage
const config = loadConfig({
env: 'production',
database: {
host: 'db.example.com',
database: 'myapp',
username: 'admin',
password: 'secret',
},
apiKeys: {
stripe: 'sk_live_xxx',
sendgrid: 'SG.xxx',
},
});
Exercises
Exercise 1: User Profile Schema
Create a Zod schema for a user profile with the following requirements:
username: 3-20 characters, alphanumeric and underscores onlyemail: valid email formatage: optional, must be between 13 and 120website: optional, must be valid URLbio: optional, max 500 characters
// Your schema here
const UserProfileSchema = // ...
type UserProfile = z.infer<typeof UserProfileSchema>;
Solution
const UserProfileSchema = z.object({
username: z
.string()
.min(3, 'Username must be at least 3 characters')
.max(20, 'Username must be at most 20 characters')
.regex(/^[a-zA-Z0-9_]+$/, 'Username can only contain letters, numbers, and underscores'),
email: z.string().email('Invalid email address'),
age: z
.number()
.int('Age must be a whole number')
.min(13, 'Must be at least 13 years old')
.max(120, 'Invalid age')
.optional(),
website: z.string().url('Invalid URL').optional(),
bio: z.string().max(500, 'Bio must be 500 characters or less').optional(),
});
type UserProfile = z.infer<typeof UserProfileSchema>;
// Test
const profile = UserProfileSchema.parse({
username: 'alice_123',
email: 'alice@example.com',
age: 25,
bio: 'Hello world!',
});
Exercise 2: API Response with Transform
Create a schema that validates this API response and transforms it to a cleaner format:
// API returns:
const apiResponse = {
data: {
user_id: 123,
user_name: 'Alice Smith',
created_at: '2024-01-15T10:30:00Z',
is_active: true,
},
meta: {
request_id: 'abc-123',
},
};
// You want:
// {
// id: 123,
// name: "Alice Smith",
// createdAt: Date,
// isActive: true
// }
Solution
const ApiResponseSchema = z
.object({
data: z.object({
user_id: z.number(),
user_name: z.string(),
created_at: z.string(),
is_active: z.boolean(),
}),
meta: z.object({
request_id: z.string(),
}),
})
.transform((response) => ({
id: response.data.user_id,
name: response.data.user_name,
createdAt: new Date(response.data.created_at),
isActive: response.data.is_active,
}));
type User = z.output<typeof ApiResponseSchema>;
// Test
const user = ApiResponseSchema.parse({
data: {
user_id: 123,
user_name: 'Alice Smith',
created_at: '2024-01-15T10:30:00Z',
is_active: true,
},
meta: {
request_id: 'abc-123',
},
});
console.log(user);
// { id: 123, name: "Alice Smith", createdAt: Date, isActive: true }
Exercise 3: Discriminated Union
Create a schema for different types of notifications:
- Email notification: has
recipient(email) andsubject - SMS notification: has
phoneNumberandmessage - Push notification: has
deviceTokenandtitleandbody
All have a common type field.
Solution
const EmailNotificationSchema = z.object({
type: z.literal('email'),
recipient: z.string().email(),
subject: z.string().min(1),
});
const SMSNotificationSchema = z.object({
type: z.literal('sms'),
phoneNumber: z.string().regex(/^\+?[1-9]\d{1,14}$/, 'Invalid phone number'),
message: z.string().min(1).max(160),
});
const PushNotificationSchema = z.object({
type: z.literal('push'),
deviceToken: z.string().min(1),
title: z.string().min(1),
body: z.string(),
});
const NotificationSchema = z.discriminatedUnion('type', [
EmailNotificationSchema,
SMSNotificationSchema,
PushNotificationSchema,
]);
type Notification = z.infer<typeof NotificationSchema>;
// Test
const email = NotificationSchema.parse({
type: 'email',
recipient: 'user@example.com',
subject: 'Hello!',
});
const sms = NotificationSchema.parse({
type: 'sms',
phoneNumber: '+1234567890',
message: 'Your code is 1234',
});
const push = NotificationSchema.parse({
type: 'push',
deviceToken: 'abc123',
title: 'New message',
body: 'You have a new message',
});
Exercise 4: Form Validation with Password Confirmation
Create a complete registration form schema with:
- Username validation
- Email validation
- Password with strength requirements
- Password confirmation that must match
- Terms acceptance checkbox
Solution
const RegistrationFormSchema = z
.object({
username: z
.string()
.min(3, 'Username must be at least 3 characters')
.max(30, 'Username must be at most 30 characters')
.regex(/^[a-zA-Z][a-zA-Z0-9_]*$/, 'Username must start with a letter'),
email: z.string().email('Please enter a valid email address'),
password: z
.string()
.min(8, 'Password must be at least 8 characters')
.refine((val) => /[A-Z]/.test(val), 'Must contain uppercase letter')
.refine((val) => /[a-z]/.test(val), 'Must contain lowercase letter')
.refine((val) => /[0-9]/.test(val), 'Must contain a number')
.refine((val) => /[^a-zA-Z0-9]/.test(val), 'Must contain a special character'),
confirmPassword: z.string(),
acceptTerms: z.boolean(),
})
.refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
})
.refine((data) => data.acceptTerms === true, {
message: 'You must accept the terms and conditions',
path: ['acceptTerms'],
});
type RegistrationForm = z.infer<typeof RegistrationFormSchema>;
// Test valid form
const validForm = RegistrationFormSchema.safeParse({
username: 'alice123',
email: 'alice@example.com',
password: 'SecurePass1!',
confirmPassword: 'SecurePass1!',
acceptTerms: true,
});
console.log(validForm.success); // true
// Test invalid form
const invalidForm = RegistrationFormSchema.safeParse({
username: 'al',
email: 'not-an-email',
password: 'weak',
confirmPassword: 'different',
acceptTerms: false,
});
if (!invalidForm.success) {
console.log(invalidForm.error.errors);
}
Key Takeaways
- Runtime validation is essential for external data (APIs, user input)
- z.infer generates TypeScript types from schemas - single source of truth
- Use safeParse() for error handling without try/catch
- Coercion (z.coerce) handles type conversion automatically
- Transform changes data shape during validation
- Discriminated unions handle multiple object types elegantly
- Refinements add custom validation logic
- Zod schemas are composable - build complex schemas from simple ones
Resources
| Resource | Type | Level |
|---|---|---|
| Zod Documentation | Documentation | Beginner |
| Zod GitHub | Repository | Beginner |
| Total TypeScript: Zod Tutorial | Tutorial | Intermediate |
| Zod Error Handling | Documentation | Intermediate |
Next Lesson
Now that you can validate data, let us learn strategies for handling errors throughout your data processing pipeline.