Comprehensive guide to implementing and extending Zod validation in the Tikka backend.
- Architecture
- Validation Pipe
- Schema Definition
- Controller Integration
- Consistent Error Responses
- Testing
- Advanced Patterns
- Migration Guide
HTTP Request
↓
Fastify Route Handler
↓
@UsePipes(createZodPipe(MySchema))
↓
Zod safeParse(value)
↓
├─→ ✅ Valid → Transform → Controller Method
└─→ ❌ Invalid → BadRequestException
↓
Error Interceptor → JSON Response (400)
| Layer | File | Purpose |
|---|---|---|
| Pipe | src/api/rest/raffles/pipes/zod-validation.pipe.ts |
Validates and transforms incoming data |
| Schemas | src/api/rest/*/\*.schema.ts |
Define Zod schemas for DTOs |
| Error Types | src/common/validation.types.ts |
TypeScript interfaces for error responses |
src/api/rest/raffles/pipes/zod-validation.pipe.ts
export function createZodPipe<T>(schema: ZodSchema<T>) {
return class implements PipeTransform {
transform(value: unknown, _metadata: ArgumentMetadata): T {
const result = schema.safeParse(value);
if (!result.success) {
const msg = result.error.errors.map((e) => e.message).join("; ");
throw new BadRequestException({
message: msg,
errors: result.error.errors,
});
}
return result.data;
}
};
}- safeParse() — Zod safely validates input without throwing (returns
{ success, data | error }) - Check Success — If validation passes, return transformed data
- Build Error — If validation fails, throw
BadRequestExceptionwith:message: string— Concatenated error messageserrors: ZodIssue[]— Detailed error array withcode,path,message
When validation fails, NestJS's error interceptor formats it as:
{
"statusCode": 400,
"message": "String must contain at most 255 characters; Invalid email",
"errors": [
{
"code": "too_big",
"path": ["username"],
"message": "String must contain at most 255 characters",
"maximum": 255,
"type": "string",
"inclusive": true
},
{
"code": "invalid_email",
"path": ["email"],
"message": "Invalid email"
}
]
}- Collocate with Controllers: Define schemas in the same module as the controller
- File Naming:
{feature}.schema.ts(e.g.,auth.schema.ts,raffles.schema.ts) - Organization: If multiple, create
dto/directory
import { z } from 'zod';
// ──────────────────────────────────────────────────────────────
// Query Parameters Schema
// ──────────────────────────────────────────────────────────────
export const ListRafflesQuerySchema = z.object({
status: z.string().optional(),
category: z.string().optional(),
limit: z.coerce.number().int().min(1).max(100).default(20).optional(),
offset: z.coerce.number().int().min(0).default(0).optional(),
});
// Infer TypeScript type for use in controllers
export type ListRafflesQueryDto = z.infer<typeof ListRafflesQuerySchema>;
// ──────────────────────────────────────────────────────────────
// Request Body Schema
// ──────────────────────────────────────────────────────────────
export const UpsertMetadataSchema = z.object({
title: z
.string()
.trim()
.min(1, 'Title is required')
.max(255, 'Title must be at most 255 characters')
.optional(),
description: z
.string()
.trim()
.max(5000, 'Description must be at most 5000 characters')
.optional(),
image_url: z
.string()
.url('Invalid image URL')
.nullable()
.optional(),
image_urls: z
.array(z.string().url('Invalid image URL'))
.nullable()
.optional(),
category: z
.string()
.max(50, 'Category must be at most 50 characters')
.nullable()
.optional(),
metadata_cid: z
.string()
.regex(/^Qm[a-zA-Z0-9]{44}$/, 'Invalid IPFS CID')
.nullable()
.optional(),
});
export type UpsertMetadataDto = z.infer<typeof UpsertMetadataSchema>;For query parameters, numbers come as strings. Use z.coerce to auto-convert:
z.coerce.number().int().min(1).max(100).default(20)Converts: ?limit=50 → { limit: 50 } (number)
Converts: ?limit=invalid → { limit: 20 } (default applied)
Converts: ?limit=-5 → ValidationError (after coercion, validation fails)
| Validator | Example | Notes |
|---|---|---|
.string() |
z.string().min(1) |
Required non-empty string |
.string().optional() |
z.string().optional() |
Optional field |
.number() |
z.number().int().max(100) |
Integer validator |
.coerce.number() |
z.coerce.number() |
Auto-convert string to number |
.boolean() |
z.boolean() |
Boolean validator |
.date() |
z.date() |
ISO date or Date object |
.enum(['a', 'b']) |
z.enum(['pending', 'done']) |
Fixed set of values |
.array(z.string()) |
z.array(z.string().email()) |
Array of type |
.nullable() |
z.string().nullable() |
Explicitly allows null |
.url() |
z.string().url() |
URL format validation |
.email() |
z.string().email() |
Email format validation |
.datetime() |
z.string().datetime() |
ISO 8601 datetime |
.regex(/pattern/) |
z.string().regex(/^\w+$/) |
Regex pattern matching |
.refine() |
.refine(v => v.length > 5) |
Custom validation logic |
export const AuthSchema = z.object({
address: z
.string({ required_error: 'Stellar address is required' })
.min(1, { message: 'Address cannot be empty' })
.min(56, { message: 'Invalid Stellar address format' }),
password: z
.string({ required_error: 'Password is required' })
.min(8, { message: 'Password must be at least 8 characters' })
.max(128, { message: 'Password must be at most 128 characters' }),
});import { Controller, Get, Query, UsePipes } from '@nestjs/common';
import { createZodPipe } from './pipes/zod-validation.pipe';
import { ListRafflesQuerySchema, type ListRafflesQueryDto } from './raffles.schema';
@Controller('raffles')
export class RafflesController {
@Get()
@UsePipes(new (createZodPipe(ListRafflesQuerySchema))())
async list(@Query() filters: ListRafflesQueryDto) {
// ✅ filters.limit is always 1-100 (or default 20)
// ✅ filters.offset is always >= 0 (or default 0)
return this.rafflesService.list(filters);
}
}import { Body, Post, UsePipes } from '@nestjs/common';
import { createZodPipe } from './pipes/zod-validation.pipe';
import { UpsertMetadataSchema, type UpsertMetadataDto } from './raffles.schema';
@Post(':raffleId/metadata')
@UsePipes(new (createZodPipe(UpsertMetadataSchema))())
async upsertMetadata(
@Param('raffleId', ParseIntPipe) raffleId: number,
@Body() payload: UpsertMetadataDto,
) {
// ✅ payload is guaranteed to match schema
return this.rafflesService.upsertMetadata(raffleId, payload);
}@Post('search')
@UsePipes(new (createZodPipe(SearchBodySchema))())
async search(
@Query('category') category?: string, // Manual validation if needed
@Body() payload: SearchBodyDto,
) {
return this.searchService.search(category, payload);
}All validation errors follow this shape:
interface ValidationErrorResponse {
statusCode: 400;
message: string; // Concatenated error messages
errors: Array<{
code: string; // Zod error code
path: (string | number)[]; // Field path
message: string; // Human-readable error
[key: string]: any; // Additional context
}>;
}Request: GET /raffles?limit=abc&offset=-1
Response:
{
"statusCode": 400,
"message": "Expected number, received nan; Number must be greater than or equal to 0",
"errors": [
{
"code": "invalid_type",
"expected": "number",
"received": "nan",
"path": ["limit"],
"message": "Expected number, received nan"
},
{
"code": "too_small",
"minimum": 0,
"type": "number",
"inclusive": true,
"path": ["offset"],
"message": "Number must be greater than or equal to 0"
}
]
}Request: POST /auth/verify with body { "address": "..." }
Response:
{
"statusCode": 400,
"message": "signature is required; nonce is required",
"errors": [
{
"code": "invalid_type",
"expected": "string",
"received": "undefined",
"path": ["signature"],
"message": "signature is required"
},
{
"code": "invalid_type",
"expected": "string",
"received": "undefined",
"path": ["nonce"],
"message": "nonce is required"
}
]
}Request: POST /support with { "email": "invalid" }
Response:
{
"statusCode": 400,
"message": "Invalid email",
"errors": [
{
"code": "invalid_email",
"path": ["email"],
"message": "Invalid email"
}
]
}import { Test, TestingModule } from '@nestjs/testing';
import { RafflesController } from './raffles.controller';
import { RafflesService } from './raffles.service';
import { createZodPipe } from './pipes/zod-validation.pipe';
import { ListRafflesQuerySchema } from './raffles.schema';
import { BadRequestException } from '@nestjs/common';
describe('RafflesController Validation', () => {
let controller: RafflesController;
let service: RafflesService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [RafflesController],
providers: [
{
provide: RafflesService,
useValue: {
list: jest.fn().mockResolvedValue([]),
},
},
],
}).compile();
controller = module.get<RafflesController>(RafflesController);
service = module.get<RafflesService>(RafflesService);
});
describe('list', () => {
it('should accept valid query params', async () => {
await controller.list({ limit: 20, offset: 0 });
expect(service.list).toHaveBeenCalledWith({ limit: 20, offset: 0 });
});
it('should reject invalid limit (too large)', () => {
const pipe = new (createZodPipe(ListRafflesQuerySchema))();
expect(() => pipe.transform({ limit: 200, offset: 0 })).toThrow(
BadRequestException,
);
});
it('should reject invalid offset (negative)', () => {
const pipe = new (createZodPipe(ListRafflesQuerySchema))();
expect(() => pipe.transform({ limit: 20, offset: -1 })).toThrow(
BadRequestException,
);
});
it('should apply defaults', () => {
const pipe = new (createZodPipe(ListRafflesQuerySchema))();
const result = pipe.transform({});
expect(result).toEqual({ limit: 20, offset: 0 });
});
it('should coerce numeric strings', () => {
const pipe = new (createZodPipe(ListRafflesQuerySchema))();
const result = pipe.transform({ limit: '50', offset: '10' });
expect(result).toEqual({ limit: 50, offset: 10 });
});
});
});import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { RafflesModule } from './raffles.module';
describe('RafflesController (E2E)', () => {
let app: INestApplication;
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [RafflesModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
afterAll(async () => {
await app.close();
});
describe('GET /raffles', () => {
it('should return 400 with invalid limit', () => {
return request(app.getHttpServer())
.get('/raffles?limit=999')
.expect(400)
.expect((res) => {
expect(res.body).toHaveProperty('message');
expect(res.body).toHaveProperty('errors');
expect(res.body.errors[0].code).toBe('too_big');
});
});
it('should return 200 with valid params', () => {
return request(app.getHttpServer())
.get('/raffles?limit=10&offset=5')
.expect(200);
});
});
});export const ActionSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('email'),
email: z.string().email(),
}),
z.object({
type: z.literal('sms'),
phone: z.string().regex(/^\+\d{1,15}$/),
}),
z.object({
type: z.literal('push'),
deviceId: z.string().uuid(),
}),
]);
export type ActionDto = z.infer<typeof ActionSchema>;export const RaffleSchema = z.object({
title: z.string().min(1),
isPrivate: z.boolean(),
password: z.string().optional(),
}).refine(
(data) => !data.isPrivate || data.password,
{
message: 'Password is required for private raffles',
path: ['password'], // Show error on password field
}
);export const EmailSchema = z.object({
email: z
.string()
.trim() // Remove whitespace
.toLowerCase() // Normalize case
.email(),
});
// Input: { email: " User@Example.COM " }
// After transform: { email: "user@example.com" }export const CustomersSchema = z.object({
emails: z
.array(z.string().email())
.refine((items) => new Set(items).size === items.length, {
message: 'Emails must be unique',
}),
});export const UsernameSchema = z.object({
username: z
.string()
.min(3)
.refine(
async (val) => {
// Check if username exists in database
const exists = await db.users.findOne({ username: val });
return !exists;
},
{ message: 'Username already taken' }
),
});
// Note: Use `parseAsync()` instead of `safeParse()` in pipeexport class ListQueryDto {
@IsOptional()
@IsInt()
@Min(1)
@Max(100)
@Type(() => Number)
limit?: number = 20;
}
@Get()
@UsePipes(new ValidationPipe({ transform: true }))
async list(@Query() query: ListQueryDto) { ... }export const ListQuerySchema = z.object({
limit: z.coerce.number().int().min(1).max(100).default(20).optional(),
});
export type ListQueryDto = z.infer<typeof ListQuerySchema>;
@Get()
@UsePipes(new (createZodPipe(ListQuerySchema))())
async list(@Query() query: ListQueryDto) { ... }✅ Single Source of Truth — Schemas define validation + types
✅ Runtime Safety — Zod validates at runtime (not just compile-time)
✅ Better Error Messages — Customizable error messages
✅ Transform Chain — .trim(), .toLowerCase(), etc. in one place
✅ Smaller Bundle — Zod is lighter than class-validator + class-transformer
backend/src/
├── api/rest/
│ ├── raffles/
│ │ ├── raffles.controller.ts ← Controllers
│ │ ├── raffles.schema.ts ← Zod schemas
│ │ ├── metadata.schema.ts ← Other endpoint schemas
│ │ ├── dto/
│ │ │ └── list-raffles-query.dto.ts
│ │ ├── pipes/
│ │ │ └── zod-validation.pipe.ts ← Shared validation pipe
│ │ └── raffles.service.ts
│ │
│ ├── auth/
│ │ ├── auth.controller.ts
│ │ ├── auth.schema.ts ← Auth schemas
│ │ └── auth.service.ts
│ │
│ ├── users/
│ │ ├── users.schema.ts
│ │ └── dto/
│ │
│ └── ... other modules ...
│
└── common/
└── validation.types.ts ← Error types
When adding a new endpoint, follow this checklist:
- Create
module.schema.tswith Zod schemas for all inputs - Use
z.infer<typeof Schema>to generate DTO types - Apply
@UsePipes(new (createZodPipe(Schema))())to controller method - Test with invalid inputs to ensure proper error responses
- Document custom validation logic in schema comments
- Add Swagger decorators if using OpenAPI docs
- Write unit and E2E tests for validation
- VALIDATION_GUIDE.md — Quick reference
- Zod Docs
- NestJS Pipes