Custom Schemas
Learn how to create and extend validation schemas for your specific use cases.
Creating Basic Schemas
Simple Object Schema
typescript
import { z } from 'zod';
// Define a product schema
const ProductSchema = z.object({
id: z.string().uuid('Product ID must be a valid UUID'),
name: z.string().min(1, 'Product name is required').max(100),
price: z.number().positive('Price must be positive'),
category: z.enum(['electronics', 'clothing', 'books', 'home']),
inStock: z.boolean(),
tags: z.array(z.string()).optional(),
metadata: z.record(z.string()).optional()
});
// Export type for TypeScript
export type Product = z.infer<typeof ProductSchema>;
// Create validation function
export function validateProduct(data: unknown) {
try {
const product = ProductSchema.parse(data);
return { success: true, data: product };
} catch (error) {
if (error instanceof z.ZodError) {
const validationError = new ValidationError('Product validation failed', error.issues);
return { success: false, error: validationError.getFormattedMessage() };
}
return { success: false, error: 'Unknown validation error' };
}
}
Nested Object Schema
typescript
// Address schema
const AddressSchema = z.object({
street: z.string().min(1, 'Street is required'),
city: z.string().min(1, 'City is required'),
state: z.string().length(2, 'State must be 2 characters'),
zipCode: z.string().regex(/^\d{5}(-\d{4})?$/, 'Invalid ZIP code format'),
country: z.string().default('US')
});
// Customer schema with nested address
const CustomerSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1).max(100),
email: z.string().email(),
phone: z.string().regex(/^\+?[\d\s\-\(\)]+$/, 'Invalid phone format').optional(),
address: AddressSchema,
billingAddress: AddressSchema.optional(),
preferences: z.object({
newsletter: z.boolean().default(false),
smsNotifications: z.boolean().default(false),
preferredLanguage: z.string().length(2).default('en')
}).optional()
});
export type Customer = z.infer<typeof CustomerSchema>;
Schema Composition
Extending Existing Schemas
typescript
// Extend UserInputSchema for different user types
import { UserInputSchema } from './validation.js';
// Admin user with additional fields
const AdminUserSchema = UserInputSchema.extend({
role: z.enum(['admin', 'super_admin']),
permissions: z.array(z.string()),
lastLogin: z.date().optional(),
isActive: z.boolean().default(true)
});
// Guest user with minimal fields
const GuestUserSchema = UserInputSchema.pick({
name: true,
email: true
}).extend({
sessionId: z.string().uuid(),
ipAddress: z.string().ip()
});
export type AdminUser = z.infer<typeof AdminUserSchema>;
export type GuestUser = z.infer<typeof GuestUserSchema>;
Schema Merging
typescript
// Base entity schema
const BaseEntitySchema = z.object({
id: z.string().uuid(),
createdAt: z.date(),
updatedAt: z.date(),
version: z.number().int().positive()
});
// Merge with specific schemas
const ProductWithMetaSchema = ProductSchema.merge(BaseEntitySchema);
const CustomerWithMetaSchema = CustomerSchema.merge(BaseEntitySchema);
// Or use intersection
const ProductEntity = ProductSchema.and(BaseEntitySchema);
Conditional Schemas
typescript
// Schema that changes based on user type
const UserSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('customer'),
name: z.string(),
email: z.string().email(),
loyaltyPoints: z.number().int().min(0)
}),
z.object({
type: z.literal('admin'),
name: z.string(),
email: z.string().email(),
permissions: z.array(z.string()),
department: z.string()
}),
z.object({
type: z.literal('guest'),
sessionId: z.string().uuid(),
ipAddress: z.string().ip()
})
]);
export type User = z.infer<typeof UserSchema>;
Advanced Validation Patterns
Custom Validation Functions
typescript
// Custom password validation
const PasswordSchema = z.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
.regex(/\d/, 'Password must contain at least one number')
.regex(/[!@#$%^&*]/, 'Password must contain at least one special character');
// Custom date validation
const FutureDateSchema = z.date().refine(
(date) => date > new Date(),
{ message: 'Date must be in the future' }
);
// Custom business logic validation
const OrderSchema = z.object({
items: z.array(z.object({
productId: z.string().uuid(),
quantity: z.number().int().positive(),
price: z.number().positive()
})).min(1, 'Order must have at least one item'),
shippingAddress: AddressSchema,
total: z.number().positive()
}).refine(
(order) => {
const calculatedTotal = order.items.reduce(
(sum, item) => sum + (item.quantity * item.price),
0
);
return Math.abs(calculatedTotal - order.total) < 0.01;
},
{ message: 'Order total does not match item prices' }
);
Async Validation
typescript
// Async validation for unique email
const UniqueEmailSchema = z.string().email().refine(
async (email) => {
const existingUser = await db.user.findUnique({ where: { email } });
return !existingUser;
},
{ message: 'Email already exists' }
);
// Usage with async validation
async function validateUserWithUniqueEmail(userData: unknown) {
try {
const UserWithUniqueEmailSchema = UserInputSchema.extend({
email: UniqueEmailSchema
});
const user = await UserWithUniqueEmailSchema.parseAsync(userData);
return { success: true, data: user };
} catch (error) {
if (error instanceof z.ZodError) {
return { success: false, error: error.message };
}
return { success: false, error: 'Unknown validation error' };
}
}
Schema Transformations
Data Preprocessing
typescript
// Transform and validate data
const ProcessedUserSchema = z.object({
name: z.string().transform(name => name.trim().toLowerCase()),
email: z.string().email().transform(email => email.toLowerCase()),
age: z.string().transform(str => parseInt(str, 10)).pipe(z.number().int().positive()),
tags: z.string().transform(str => str.split(',').map(tag => tag.trim())).pipe(z.array(z.string()))
});
// Usage
const result = ProcessedUserSchema.safeParse({
name: ' John Doe ',
email: 'JOHN@EXAMPLE.COM',
age: '30',
tags: 'developer, typescript, node.js'
});
if (result.success) {
console.log(result.data);
// {
// name: 'john doe',
// email: 'john@example.com',
// age: 30,
// tags: ['developer', 'typescript', 'node.js']
// }
}
Default Values and Preprocessing
typescript
const ConfigWithDefaultsSchema = z.object({
database: z.object({
host: z.string().default('localhost'),
port: z.number().int().default(5432),
name: z.string(),
ssl: z.boolean().default(false)
}),
cache: z.object({
enabled: z.boolean().default(true),
ttl: z.number().int().default(3600),
maxSize: z.number().int().default(1000)
}).optional(),
features: z.record(z.boolean()).default({})
}).transform((config) => {
// Post-process the configuration
if (!config.cache) {
config.cache = {
enabled: false,
ttl: 0,
maxSize: 0
};
}
return config;
});
Schema Utilities
Schema Factory Functions
typescript
// Factory for creating paginated response schemas
function createPaginatedSchema<T extends z.ZodTypeAny>(itemSchema: T) {
return z.object({
items: z.array(itemSchema),
pagination: z.object({
page: z.number().int().positive(),
limit: z.number().int().positive(),
total: z.number().int().min(0),
totalPages: z.number().int().min(0)
})
});
}
// Usage
const PaginatedProductsSchema = createPaginatedSchema(ProductSchema);
const PaginatedUsersSchema = createPaginatedSchema(UserInputSchema);
export type PaginatedProducts = z.infer<typeof PaginatedProductsSchema>;
Schema Validation Helpers
typescript
// Generic validation helper
export function createValidator<T>(schema: z.ZodSchema<T>) {
return (data: unknown): { success: true; data: T } | { success: false; error: string } => {
try {
const validatedData = schema.parse(data);
return { success: true, data: validatedData };
} catch (error) {
if (error instanceof z.ZodError) {
const validationError = new ValidationError('Validation failed', error.issues);
return { success: false, error: validationError.getFormattedMessage() };
}
return { success: false, error: 'Unknown validation error' };
}
};
}
// Create validators
export const validateProduct = createValidator(ProductSchema);
export const validateCustomer = createValidator(CustomerSchema);
export const validateOrder = createValidator(OrderSchema);
Schema Testing Utilities
typescript
// Generate test data from schema
import { faker } from '@faker-js/faker';
function generateTestData<T>(schema: z.ZodSchema<T>): T {
// This is a simplified example - you'd need a more sophisticated generator
if (schema instanceof z.ZodObject) {
const shape = schema.shape;
const testData: any = {};
for (const [key, fieldSchema] of Object.entries(shape)) {
if (fieldSchema instanceof z.ZodString) {
testData[key] = faker.lorem.word();
} else if (fieldSchema instanceof z.ZodNumber) {
testData[key] = faker.number.int({ min: 1, max: 100 });
} else if (fieldSchema instanceof z.ZodBoolean) {
testData[key] = faker.datatype.boolean();
}
// Add more type handlers as needed
}
return testData;
}
throw new Error('Unsupported schema type for test data generation');
}
// Usage in tests
const testProduct = generateTestData(ProductSchema);
const testUser = generateTestData(UserInputSchema);
Integration Patterns
Database Schema Sync
typescript
// Keep Zod schemas in sync with database schemas
import { z } from 'zod';
// Database table definition (Prisma example)
// model User {
// id String @id @default(uuid())
// name String
// email String @unique
// createdAt DateTime @default(now())
// updatedAt DateTime @updatedAt
// }
// Corresponding Zod schema
const DatabaseUserSchema = z.object({
id: z.string().uuid(),
name: z.string(),
email: z.string().email(),
createdAt: z.date(),
updatedAt: z.date()
});
// Input schema (subset for creation)
const CreateUserSchema = DatabaseUserSchema.omit({
id: true,
createdAt: true,
updatedAt: true
});
// Update schema (all fields optional except id)
const UpdateUserSchema = DatabaseUserSchema.partial().required({ id: true });
API Schema Versioning
typescript
// Version 1 schema
const UserV1Schema = z.object({
name: z.string(),
email: z.string().email()
});
// Version 2 schema (with additional fields)
const UserV2Schema = UserV1Schema.extend({
phone: z.string().optional(),
preferences: z.object({
newsletter: z.boolean().default(false)
}).optional()
});
// Version-aware validation
function validateUserByVersion(data: unknown, version: string) {
switch (version) {
case 'v1':
return createValidator(UserV1Schema)(data);
case 'v2':
return createValidator(UserV2Schema)(data);
default:
return { success: false, error: 'Unsupported API version' };
}
}
Best Practices
✅ Do
- Keep schemas focused - One schema per logical entity
- Use descriptive error messages - Help users understand validation failures
- Leverage TypeScript inference - Let Zod generate your types
- Compose schemas - Build complex schemas from simple ones
- Test your schemas - Write tests for validation logic
❌ Don't
- Create overly complex schemas - Break them down into smaller pieces
- Ignore performance - Cache compiled schemas for repeated use
- Skip error handling - Always handle validation failures
- Hardcode validation logic - Use schemas for consistency
- Forget about backwards compatibility - Version your schemas