Skip to main content

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);
});
});

Resources​