Zod Validation
Overviewβ
The application uses Zod with nestjs-zod for schema validation. Zod provides a TypeScript-first schema validation with static type inference, ensuring both runtime and compile-time type safety.
What is Zod?β
Zod is:
- TypeScript-First: Infer TypeScript types from schemas
- Composable: Build complex schemas from simple ones
- Immutable: Schemas are immutable objects
- Single Source of Truth: One schema for validation and types
- Comprehensive: Extensive built-in validators
- Extensible: Create custom validators
App Validation Setupβ
Installationβ
pnpm add zod nestjs-zod
pnpm add -D @types/zod
Schema Files Locationβ
packages/schemas/
βββ auth/
β βββ login.schema.ts
β βββ signup.schema.ts
β βββ refresh-token.schema.ts
βββ employees/
β βββ create-employee.schema.ts
β βββ update-employee.schema.ts
β βββ list-employees.schema.ts
βββ systems/
β βββ create-system.schema.ts
β βββ list-systems.schema.ts
βββ errors/
β βββ api-error.schema.ts
βββ index.ts
Schema Definitionsβ
Basic Schema Exampleβ
packages/schemas/employees/create-employee.schema.tsβ
import { z } from 'zod';
export const CreateEmployeeSchema = z.object({
name: z
.string({ required_error: 'Name is required' })
.min(1, 'Name cannot be empty')
.max(255, 'Name cannot exceed 255 characters'),
email: z
.string({ required_error: 'Email is required' })
.email('Invalid email format')
.toLowerCase(),
department: z
.string({ required_error: 'Department is required' })
.min(1, 'Department cannot be empty'),
role: z.string({ required_error: 'Role is required' }).min(1, 'Role cannot be empty'),
phone: z
.string()
.regex(/^\+?[1-9]\d{1,14}$/, 'Invalid phone number')
.optional(),
});
// Infer TypeScript type from schema
export type CreateEmployeeRequest = z.infer<typeof CreateEmployeeSchema>;
String Validatorsβ
const nameSchema = z.object({
// Basic
name: z.string(),
// Length
name: z.string().min(1),
name: z.string().max(255),
name: z.string().min(1).max(255),
name: z.string().length(10),
// Pattern
email: z.string().email(),
url: z.string().url(),
uuid: z.string().uuid(),
// Custom regex
code: z.string().regex(/^[A-Z]{2}\d{3}$/),
// Trim and normalize
email: z.string().trim().toLowerCase(),
});
Number Validatorsβ
const numbersSchema = z.object({
age: z.number().int().min(0).max(150),
salary: z.number().positive('Salary must be positive'),
score: z.number().min(0).max(100),
percentage: z.number().multipleOf(0.01),
port: z.number().int().min(1).max(65535),
});
Date Validatorsβ
const dateSchema = z.object({
birthday: z.coerce
.date()
.min(new Date('1900-01-01'), 'Invalid birth date')
.max(new Date(), 'Birth date cannot be in the future'),
startDate: z.coerce.date(),
timestamp: z
.number()
.int()
.positive()
.transform((v) => new Date(v)),
});
Array Validatorsβ
const arraySchema = z.object({
// Basic array
tags: z.array(z.string()),
// Non-empty array
tags: z.array(z.string()).min(1),
// Fixed length
coordinates: z.array(z.number()).length(2),
// Min/max
items: z.array(z.string()).min(1).max(10),
// Complex items
employees: z.array(
z.object({
id: z.string().uuid(),
name: z.string(),
}),
),
});
Enum Validatorsβ
const departmentEnum = z.enum(['Engineering', 'Sales', 'Marketing', 'HR']);
const roleEnum = z.enum(['Admin', 'Manager', 'Employee', 'Viewer']);
const userSchema = z.object({
department: departmentEnum,
role: roleEnum,
});
// Infer as literal union type
type Department = z.infer<typeof departmentEnum>;
// type Department = 'Engineering' | 'Sales' | 'Marketing' | 'HR'
Optional and Nullableβ
const employeeSchema = z.object({
// Optional - field can be omitted
phone: z.string().optional(),
// Nullable - field can be null
address: z.string().nullable(),
// Optional and nullable
middleName: z.string().optional().nullable(),
// With default
isActive: z.boolean().default(true),
});
Complex Schemasβ
Nested Objectsβ
const employeeSchema = z.object({
name: z.string(),
contact: z.object({
email: z.string().email(),
phone: z.string(),
address: z.object({
street: z.string(),
city: z.string(),
zipCode: z.string(),
}),
}),
});
Union Typesβ
const notificationSchema = z.union([
z.object({
type: z.literal('email'),
email: z.string().email(),
}),
z.object({
type: z.literal('sms'),
phone: z.string(),
}),
]);
Discriminated Unionsβ
const resultSchema = z.discriminatedUnion('status', [
z.object({
status: z.literal('success'),
data: z.object({ id: z.string() }),
}),
z.object({
status: z.literal('error'),
error: z.string(),
}),
]);
Partial and Extendβ
const createEmployeeSchema = z.object({
name: z.string(),
email: z.string().email(),
department: z.string(),
});
// Partial - all fields optional
const updateEmployeeSchema = createEmployeeSchema.partial();
// Pick specific fields
const employeeDetailsSchema = createEmployeeSchema.pick({
name: true,
email: true,
});
// Omit fields
const employeeWithoutEmailSchema = createEmployeeSchema.omit({
email: true,
});
// Extend/merge
const fullEmployeeSchema = createEmployeeSchema.extend({
id: z.string().uuid(),
createdAt: z.date(),
});
Custom Validatorsβ
Refineβ
const passwordSchema = z
.string()
.min(8, 'Password must be at least 8 characters')
.refine((val) => /[A-Z]/.test(val), 'Password must contain at least one uppercase letter')
.refine((val) => /[0-9]/.test(val), 'Password must contain at least one number');
SuperRefineβ
const userSchema = z
.object({
password: z.string(),
confirmPassword: z.string(),
})
.superRefine(({ password, confirmPassword }, ctx) => {
if (password !== confirmPassword) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['confirmPassword'],
message: 'Passwords do not match',
});
}
});
Custom Error Messagesβ
const employeeSchema = z.object({
email: z.string().email({
message: 'Please provide a valid email address',
}),
age: z.number().int().positive({
message: 'Age must be a positive integer',
}),
});
Data Transformationβ
Transformβ
const trimmedStringSchema = z.string().transform((val) => val.trim());
const lowerCaseEmailSchema = z
.string()
.email()
.transform((val) => val.toLowerCase());
const dateFromUnixSchema = z.number().transform((val) => new Date(val * 1000));
Pipeβ
const processedSchema = z
.string()
.pipe(z.string().trim())
.pipe(z.string().toLowerCase())
.pipe(z.string().email());
Pre and Post Processingβ
const birthDateSchema = z
.string()
.or(z.date())
.pipe(z.coerce.date())
.refine((date) => date < new Date(), {
message: 'Birth date cannot be in the future',
});
Integration with NestJSβ
ZodValidationPipeβ
import { ZodValidationPipe } from 'nestjs-zod';
@Controller('employees')
export class EmployeesController {
@Post()
async create(
@Body(new ZodValidationPipe(CreateEmployeeSchema))
data: CreateEmployeeRequest,
) {
// data is type-safe here
return this.employeesService.create(data);
}
}
Global Validationβ
// main.ts
import { ZodSerializerInterceptor } from 'nestjs-zod';
app.useGlobalInterceptors(ZodSerializerInterceptor);
Query Parametersβ
const listEmployeesSchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().max(100).default(20),
department: z.string().optional(),
});
@Get()
async findAll(
@Query(new ZodValidationPipe(listEmployeesSchema))
query: z.infer<typeof listEmployeesSchema>,
) {
return this.employeesService.findAll(query);
}
Error Handlingβ
Validation Errorsβ
const schema = z.object({
email: z.string().email(),
age: z.number().min(18),
});
try {
schema.parse({
email: 'invalid',
age: 10,
});
} catch (error) {
if (error instanceof z.ZodError) {
console.log(error.errors);
// [
// { code: 'invalid_string', path: ['email'], message: 'Invalid email' },
// { code: 'too_small', path: ['age'], message: 'Number must be >= 18' }
// ]
}
}
Safe Parsingβ
const result = schema.safeParse({
email: 'test@example.com',
age: 25,
});
if (result.success) {
console.log(result.data); // Valid data
} else {
console.log(result.error.errors); // Validation errors
}
Schema Compositionβ
Reusable Schemasβ
// Base schemas
const uuidSchema = z.string().uuid();
const emailSchema = z.string().email().toLowerCase();
const timestampSchema = z.date();
// Composed schemas
const userBaseSchema = z.object({
email: emailSchema,
});
const userWithTimestampsSchema = userBaseSchema.extend({
createdAt: timestampSchema,
updatedAt: timestampSchema,
});
// Full user schema
const userSchema = userWithTimestampsSchema.extend({
id: uuidSchema,
name: z.string(),
department: z.string(),
});
Schema Registryβ
// packages/schemas/index.ts
export * from './employees/create-employee.schema';
export * from './employees/update-employee.schema';
export * from './auth/login.schema';
export * from './auth/signup.schema';
Best Practicesβ
1. Keep Schemas Close to Usageβ
// β
GOOD: Schema near controller
@Controller('employees')
export class EmployeesController {
@Post()
create(
@Body(new ZodValidationPipe(CreateEmployeeSchema))
data: CreateEmployeeRequest,
) {}
}
// β AVOID: Generic schema
const dataSchema = z.object({
field1: z.string(),
field2: z.string(),
});
2. Use Descriptive Error Messagesβ
// β
GOOD
const schema = z.object({
email: z.string().email('Please provide a valid email address'),
age: z.number().min(18, 'You must be at least 18 years old'),
});
// β AVOID
const schema = z.object({
email: z.string().email(),
age: z.number().min(18),
});
3. Compose Over Duplicationβ
// β
GOOD
const createSchema = z.object({
name: z.string(),
email: z.string().email(),
});
const updateSchema = createSchema.partial();
// β AVOID
const createSchema = z.object({
name: z.string(),
email: z.string().email(),
});
const updateSchema = z.object({
name: z.string().optional(),
email: z.string().email().optional(),
});
4. Transform Input Dataβ
// β
GOOD: Transform during validation
const emailSchema = z
.string()
.email()
.transform((val) => val.toLowerCase().trim());
// β AVOID: Manual transformation
const email = input.email.toLowerCase().trim();
Testing Validationβ
describe('CreateEmployeeSchema', () => {
it('should accept valid data', () => {
const data = {
name: 'John Doe',
email: 'john@example.com',
department: 'Engineering',
role: 'Developer',
};
const result = CreateEmployeeSchema.safeParse(data);
expect(result.success).toBe(true);
});
it('should reject invalid email', () => {
const data = {
name: 'John',
email: 'invalid-email',
department: 'Engineering',
role: 'Developer',
};
const result = CreateEmployeeSchema.safeParse(data);
expect(result.success).toBe(false);
expect(result.error?.errors[0].path).toContain('email');
});
it('should reject missing required fields', () => {
const data = { name: 'John' };
const result = CreateEmployeeSchema.safeParse(data);
expect(result.success).toBe(false);
});
});