755 lines
18 KiB
Markdown
755 lines
18 KiB
Markdown
# 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
|