# Validation Patterns - Input Validation with Zod
Complete guide to input validation using Zod schemas for type-safe validation.
## Table of Contents
- [Why Zod?](#why-zod)
- [Basic Zod Patterns](#basic-zod-patterns)
- [Schema Examples from Codebase](#schema-examples-from-codebase)
- [Route-Level Validation](#route-level-validation)
- [Controller Validation](#controller-validation)
- [DTO Pattern](#dto-pattern)
- [Error Handling](#error-handling)
- [Advanced Patterns](#advanced-patterns)
---
## Why Zod?
### Benefits Over Joi/Other Libraries
**Type Safety:**
- ✅ Full TypeScript inference
- ✅ Runtime + compile-time validation
- ✅ Automatic type generation
**Developer Experience:**
- ✅ Intuitive API
- ✅ Composable schemas
- ✅ Excellent error messages
**Performance:**
- ✅ Fast validation
- ✅ Small bundle size
- ✅ Tree-shakeable
### Migration from Joi
Modern validation uses Zod instead of Joi:
```typescript
// ❌ OLD - Joi (being phased out)
const schema = Joi.object({
email: Joi.string().email().required(),
name: Joi.string().min(3).required(),
});
// ✅ NEW - Zod (preferred)
const schema = z.object({
email: z.string().email(),
name: z.string().min(3),
});
```
---
## Basic Zod Patterns
### Primitive Types
```typescript
import { z } from 'zod';
// Strings
const nameSchema = z.string();
const emailSchema = z.string().email();
const urlSchema = z.string().url();
const uuidSchema = z.string().uuid();
const minLengthSchema = z.string().min(3);
const maxLengthSchema = z.string().max(100);
// Numbers
const ageSchema = z.number().int().positive();
const priceSchema = z.number().positive();
const rangeSchema = z.number().min(0).max(100);
// Booleans
const activeSchema = z.boolean();
// Dates
const dateSchema = z.string().datetime(); // ISO 8601 string
const nativeDateSchema = z.date(); // Native Date object
// Enums
const roleSchema = z.enum(['admin', 'operations', 'user']);
const statusSchema = z.enum(['PENDING', 'APPROVED', 'REJECTED']);
```
### Objects
```typescript
// Simple object
const userSchema = z.object({
email: z.string().email(),
name: z.string(),
age: z.number().int().positive(),
});
// Nested objects
const addressSchema = z.object({
street: z.string(),
city: z.string(),
zipCode: z.string().regex(/^\d{5}$/),
});
const userWithAddressSchema = z.object({
name: z.string(),
address: addressSchema,
});
// Optional fields
const userSchema = z.object({
name: z.string(),
email: z.string().email().optional(),
phone: z.string().optional(),
});
// Nullable fields
const userSchema = z.object({
name: z.string(),
middleName: z.string().nullable(),
});
```
### Arrays
```typescript
// Array of primitives
const rolesSchema = z.array(z.string());
const numbersSchema = z.array(z.number());
// Array of objects
const usersSchema = z.array(
z.object({
id: z.string(),
name: z.string(),
})
);
// Array with constraints
const tagsSchema = z.array(z.string()).min(1).max(10);
const nonEmptyArray = z.array(z.string()).nonempty();
```
---
## Schema Examples from Codebase
### Form Validation Schemas
**File:** `/form/src/helpers/zodSchemas.ts`
```typescript
import { z } from 'zod';
// Question types enum
export const questionTypeSchema = z.enum([
'input',
'textbox',
'editor',
'dropdown',
'autocomplete',
'checkbox',
'radio',
'upload',
]);
// Upload types
export const uploadTypeSchema = z.array(
z.enum(['pdf', 'image', 'excel', 'video', 'powerpoint', 'word']).nullable()
);
// Input types
export const inputTypeSchema = z
.enum(['date', 'number', 'input', 'currency'])
.nullable();
// Question option
export const questionOptionSchema = z.object({
id: z.number().int().positive().optional(),
controlTag: z.string().max(150).nullable().optional(),
label: z.string().max(100).nullable().optional(),
order: z.number().int().min(0).default(0),
});
// Question schema
export const questionSchema = z.object({
id: z.number().int().positive().optional(),
formID: z.number().int().positive(),
sectionID: z.number().int().positive().optional(),
options: z.array(questionOptionSchema).optional(),
label: z.string().max(500),
description: z.string().max(5000).optional(),
type: questionTypeSchema,
uploadTypes: uploadTypeSchema.optional(),
inputType: inputTypeSchema.optional(),
tags: z.array(z.string().max(150)).optional(),
required: z.boolean(),
isStandard: z.boolean().optional(),
deprecatedKey: z.string().nullable().optional(),
maxLength: z.number().int().positive().nullable().optional(),
isOptionsSorted: z.boolean().optional(),
});
// Form section schema
export const formSectionSchema = z.object({
id: z.number().int().positive(),
formID: z.number().int().positive(),
questions: z.array(questionSchema).optional(),
label: z.string().max(500),
description: z.string().max(5000).optional(),
isStandard: z.boolean(),
});
// Create form schema
export const createFormSchema = z.object({
id: z.number().int().positive(),
label: z.string().max(150),
description: z.string().max(6000).nullable().optional(),
isPhase: z.boolean().optional(),
username: z.string(),
});
// Update order schema
export const updateOrderSchema = z.object({
source: z.object({
index: z.number().int().min(0),
sectionID: z.number().int().min(0),
}),
destination: z.object({
index: z.number().int().min(0),
sectionID: z.number().int().min(0),
}),
});
// Controller-specific validation schemas
export const createQuestionValidationSchema = z.object({
formID: z.number().int().positive(),
sectionID: z.number().int().positive(),
question: questionSchema,
index: z.number().int().min(0).nullable().optional(),
username: z.string(),
});
export const updateQuestionValidationSchema = z.object({
questionID: z.number().int().positive(),
username: z.string(),
question: questionSchema,
});
```
### Proxy Relationship Schema
```typescript
// Proxy relationship validation
const createProxySchema = z.object({
originalUserID: z.string().min(1),
proxyUserID: z.string().min(1),
startsAt: z.string().datetime(),
expiresAt: z.string().datetime(),
});
// With custom validation
const createProxySchemaWithValidation = createProxySchema.refine(
(data) => new Date(data.expiresAt) > new Date(data.startsAt),
{
message: 'expiresAt must be after startsAt',
path: ['expiresAt'],
}
);
```
### Workflow Validation
```typescript
// Workflow start schema
const startWorkflowSchema = z.object({
workflowCode: z.string().min(1),
entityType: z.enum(['Post', 'User', 'Comment']),
entityID: z.number().int().positive(),
dryRun: z.boolean().optional().default(false),
});
// Workflow step completion schema
const completeStepSchema = z.object({
stepInstanceID: z.number().int().positive(),
answers: z.record(z.string(), z.any()),
dryRun: z.boolean().optional().default(false),
});
```
---
## Route-Level Validation
### Pattern 1: Inline Validation
```typescript
// routes/proxyRoutes.ts
import { z } from 'zod';
const createProxySchema = z.object({
originalUserID: z.string().min(1),
proxyUserID: z.string().min(1),
startsAt: z.string().datetime(),
expiresAt: z.string().datetime(),
});
router.post(
'/',
SSOMiddlewareClient.verifyLoginStatus,
async (req, res) => {
try {
// Validate at route level
const validated = createProxySchema.parse(req.body);
// Delegate to service
const proxy = await proxyService.createProxyRelationship(validated);
res.status(201).json({ success: true, data: proxy });
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({
success: false,
error: {
message: 'Validation failed',
details: error.errors,
},
});
}
handler.handleException(res, error);
}
}
);
```
**Pros:**
- Quick and simple
- Good for simple routes
**Cons:**
- Validation logic in routes
- Harder to test
- Not reusable
---
## Controller Validation
### Pattern 2: Controller Validation (Recommended)
```typescript
// validators/userSchemas.ts
import { z } from 'zod';
export const createUserSchema = z.object({
email: z.string().email(),
name: z.string().min(2).max(100),
roles: z.array(z.enum(['admin', 'operations', 'user'])),
isActive: z.boolean().default(true),
});
export const updateUserSchema = z.object({
email: z.string().email().optional(),
name: z.string().min(2).max(100).optional(),
roles: z.array(z.enum(['admin', 'operations', 'user'])).optional(),
isActive: z.boolean().optional(),
});
export type CreateUserDTO = z.infer<typeof createUserSchema>;
export type UpdateUserDTO = z.infer<typeof updateUserSchema>;
```
```typescript
// controllers/UserController.ts
import { Request, Response } from 'express';
import { BaseController } from './BaseController';
import { UserService } from '../services/userService';
import { createUserSchema, updateUserSchema } from '../validators/userSchemas';
import { z } from 'zod';
export class UserController extends BaseController {
private userService: UserService;
constructor() {
super();
this.userService = new UserService();
}
async createUser(req: Request, res: Response): Promise<void> {
try {
// Validate input
const validated = createUserSchema.parse(req.body);
// Call service
const user = await this.userService.createUser(validated);
this.handleSuccess(res, user, 'User created successfully', 201);
} catch (error) {
if (error instanceof z.ZodError) {
// Handle validation errors with 400 status
return this.handleError(error, res, 'createUser', 400);
}
this.handleError(error, res, 'createUser');
}
}
async updateUser(req: Request, res: Response): Promise<void> {
try {
// Validate params and body
const userId = req.params.id;
const validated = updateUserSchema.parse(req.body);
const user = await this.userService.updateUser(userId, validated);
this.handleSuccess(res, user, 'User updated successfully');
} catch (error) {
if (error instanceof z.ZodError) {
return this.handleError(error, res, 'updateUser', 400);
}
this.handleError(error, res, 'updateUser');
}
}
}
```
**Pros:**
- Clean separation
- Reusable schemas
- Easy to test
- Type-safe DTOs
**Cons:**
- More files to manage
---
## DTO Pattern
### Type Inference from Schemas
```typescript
import { z } from 'zod';
// Define schema
const createUserSchema = z.object({
email: z.string().email(),
name: z.string(),
age: z.number().int().positive(),
});
// Infer TypeScript type from schema
type CreateUserDTO = z.infer<typeof createUserSchema>;
// Equivalent to:
// type CreateUserDTO = {
// email: string;
// name: string;
// age: number;
// }
// Use in service
class UserService {
async createUser(data: CreateUserDTO): Promise<User> {
// data is fully typed!
console.log(data.email); // ✅ TypeScript knows this exists
console.log(data.invalid); // ❌ TypeScript error!
}
}
```
### Input vs Output Types
```typescript
// Input schema (what API receives)
const createUserInputSchema = z.object({
email: z.string().email(),
name: z.string(),
password: z.string().min(8),
});
// Output schema (what API returns)
const userOutputSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
name: z.string(),
createdAt: z.string().datetime(),
// password excluded!
});
type CreateUserInput = z.infer<typeof createUserInputSchema>;
type UserOutput = z.infer<typeof userOutputSchema>;
```
---
## Error Handling
### Zod Error Format
```typescript
try {
const validated = schema.parse(data);
} catch (error) {
if (error instanceof z.ZodError) {
console.log(error.errors);
// [
// {
// code: 'invalid_type',
// expected: 'string',
// received: 'number',
// path: ['email'],
// message: 'Expected string, received number'
// }
// ]
}
}
```
### Custom Error Messages
```typescript
const userSchema = z.object({
email: z.string().email({ message: 'Please provide a valid email address' }),
name: z.string().min(2, { message: 'Name must be at least 2 characters' }),
age: z.number().int().positive({ message: 'Age must be a positive number' }),
});
```
### Formatted Error Response
```typescript
// Helper function to format Zod errors
function formatZodError(error: z.ZodError) {
return {
message: 'Validation failed',
errors: error.errors.map((err) => ({
field: err.path.join('.'),
message: err.message,
code: err.code,
})),
};
}
// In controller
catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({
success: false,
error: formatZodError(error),
});
}
}
// Response example:
// {
// "success": false,
// "error": {
// "message": "Validation failed",
// "errors": [
// {
// "field": "email",
// "message": "Invalid email",
// "code": "invalid_string"
// }
// ]
// }
// }
```
---
## Advanced Patterns
### Conditional Validation
```typescript
// Validate based on other field values
const submissionSchema = z.object({
type: z.enum(['NEW', 'UPDATE']),
postId: z.number().optional(),
}).refine(
(data) => {
// If type is UPDATE, postId is required
if (data.type === 'UPDATE') {
return data.postId !== undefined;
}
return true;
},
{
message: 'postId is required when type is UPDATE',
path: ['postId'],
}
);
```
### Transform Data
```typescript
// Transform strings to numbers
const userSchema = z.object({
name: z.string(),
age: z.string().transform((val) => parseInt(val, 10)),
});
// Transform dates
const eventSchema = z.object({
name: z.string(),
date: z.string().transform((str) => new Date(str)),
});
```
### Preprocess Data
```typescript
// Trim strings before validation
const userSchema = z.object({
email: z.preprocess(
(val) => typeof val === 'string' ? val.trim().toLowerCase() : val,
z.string().email()
),
name: z.preprocess(
(val) => typeof val === 'string' ? val.trim() : val,
z.string().min(2)
),
});
```
### Union Types
```typescript
// Multiple possible types
const idSchema = z.union([z.string(), z.number()]);
// Discriminated unions
const notificationSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('email'),
recipient: z.string().email(),
subject: z.string(),
}),
z.object({
type: z.literal('sms'),
phoneNumber: z.string(),
message: z.string(),
}),
]);
```
### Recursive Schemas
```typescript
// For nested structures like trees
type Category = {
id: number;
name: string;
children?: Category[];
};
const categorySchema: z.ZodType<Category> = z.lazy(() =>
z.object({
id: z.number(),
name: z.string(),
children: z.array(categorySchema).optional(),
})
);
```
### Schema Composition
```typescript
// Base schemas
const timestampsSchema = z.object({
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
});
const auditSchema = z.object({
createdBy: z.string(),
updatedBy: z.string(),
});
// Compose schemas
const userSchema = z.object({
id: z.string(),
email: z.string().email(),
name: z.string(),
}).merge(timestampsSchema).merge(auditSchema);
// Extend schemas
const adminUserSchema = userSchema.extend({
adminLevel: z.number().int().min(1).max(5),
permissions: z.array(z.string()),
});
// Pick specific fields
const publicUserSchema = userSchema.pick({
id: true,
name: true,
// email excluded
});
// Omit fields
const userWithoutTimestamps = userSchema.omit({
createdAt: true,
updatedAt: true,
});
```
### Validation Middleware
```typescript
// Create reusable validation middleware
import { Request, Response, NextFunction } from 'express';
import { z } from 'zod';
export function validateBody<T extends z.ZodType>(schema: T) {
return (req: Request, res: Response, next: NextFunction) => {
try {
req.body = schema.parse(req.body);
next();
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({
success: false,
error: {
message: 'Validation failed',
details: error.errors,
},
});
}
next(error);
}
};
}
// Usage
router.post('/users',
validateBody(createUserSchema),
async (req, res) => {
// req.body is validated and typed!
const user = await userService.createUser(req.body);
res.json({ success: true, data: user });
}
);
```
---
**Related Files:**
- [SKILL.md](SKILL.md) - Main guide
- [routing-and-controllers.md](routing-and-controllers.md) - Using validation in controllers
- [services-and-repositories.md](services-and-repositories.md) - Using DTOs in services
- [async-and-errors.md](async-and-errors.md) - Error handling patterns