Validation with Zod
Master request validation, custom rules, and error handling with Zod
Validation with Zod
Learn how to customize and extend validation rules for your DataBridge API.
Overview
DataBridge uses Zod for runtime type validation. This guide covers:
- Understanding auto-generated schemas
- Adding custom validation rules
- Handling validation errors
- Best practices and patterns
Auto-Generated Schemas
When you run databridge generate, Zod schemas are created based on your Prisma types.
Prisma to Zod Mapping
| Prisma Type | Zod Type | Example |
|---|---|---|
String | z.string() | name: z.string() |
Int | z.number().int() | age: z.number().int() |
Float/Decimal | z.number() | price: z.number() |
Boolean | z.boolean() | is_active: z.boolean() |
DateTime | z.date() or z.string().datetime() | created_at: z.date() |
String? (optional) | z.string().optional() | bio: z.string().optional() |
Enum | z.enum([...]) | role: z.enum(['admin', 'user']) |
Example Generated Schema
Prisma:
model products {
id Int @id @default(autoincrement())
name String @db.VarChar(255)
description String? @db.Text
price Decimal @db.Decimal(10, 2)
stock Int? @default(0)
category String? @db.VarChar(100)
is_active Boolean? @default(true)
created_at DateTime? @default(now())
}
Generated Zod:
const productsSchema = z.object({
name: z.string().min(1, "Name is required").max(255),
description: z.string().optional(),
price: z.number().positive("Price must be positive"),
stock: z.number().int().nonnegative("Stock cannot be negative").optional(),
category: z.string().max(100).optional(),
is_active: z.boolean().optional(),
});
Customizing Validation Rules
String Validation
const userSchema = z.object({
// Basic string
name: z.string(),
// Length constraints
username: z.string()
.min(3, "Username must be at least 3 characters")
.max(20, "Username cannot exceed 20 characters"),
// Email validation
email: z.string()
.email("Invalid email format"),
// URL validation
website: z.string()
.url("Invalid URL format")
.optional(),
// Regex pattern
phone: z.string()
.regex(/^\+?[1-9]\d{1,14}$/, "Invalid phone number"),
// Custom validation
password: z.string()
.min(8, "Password must be at least 8 characters")
.regex(/[A-Z]/, "Password must contain uppercase letter")
.regex(/[a-z]/, "Password must contain lowercase letter")
.regex(/[0-9]/, "Password must contain number")
.regex(/[^A-Za-z0-9]/, "Password must contain special character"),
// Non-empty string
bio: z.string()
.min(1, "Bio cannot be empty")
.max(500)
.optional(),
});
Number Validation
const productSchema = z.object({
// Integer
stock: z.number()
.int("Stock must be an integer")
.nonnegative("Stock cannot be negative"),
// Positive number
price: z.number()
.positive("Price must be positive"),
// Range validation
rating: z.number()
.min(1, "Rating must be at least 1")
.max(5, "Rating cannot exceed 5"),
// Multiple of
discount_percent: z.number()
.min(0)
.max(100)
.multipleOf(5, "Discount must be multiple of 5"),
// Custom validation
age: z.number()
.int()
.refine(val => val >= 18, "Must be at least 18 years old"),
});
Boolean Validation
const settingsSchema = z.object({
is_active: z.boolean(),
// With default
notifications_enabled: z.boolean()
.default(true),
// Optional
is_verified: z.boolean().optional(),
});
Date Validation
const eventSchema = z.object({
// Date object
start_date: z.date(),
// ISO string
end_date: z.string()
.datetime("Invalid date format"),
// Custom validation
birth_date: z.date()
.refine(
date => date < new Date(),
"Birth date must be in the past"
),
// Date range
event_date: z.date()
.refine(
date => {
const minDate = new Date('2024-01-01');
const maxDate = new Date('2024-12-31');
return date >= minDate && date <= maxDate;
},
"Event must be in 2024"
),
});
Enum Validation
const orderSchema = z.object({
// Simple enum
status: z.enum(['pending', 'paid', 'shipped', 'delivered']),
// With default
priority: z.enum(['low', 'medium', 'high'])
.default('medium'),
// Optional enum
shipping_method: z.enum(['standard', 'express', 'overnight'])
.optional(),
});
Advanced Validation Patterns
Conditional Validation
const orderSchema = z.object({
payment_method: z.enum(['card', 'paypal', 'bank_transfer']),
// Required if payment_method is 'card'
card_number: z.string().optional(),
// Required if payment_method is 'bank_transfer'
bank_account: z.string().optional(),
}).refine(
data => {
if (data.payment_method === 'card') {
return !!data.card_number;
}
if (data.payment_method === 'bank_transfer') {
return !!data.bank_account;
}
return true;
},
{
message: "Payment details required for selected method",
path: ['payment_method'],
}
);
Cross-Field Validation
const bookingSchema = z.object({
check_in: z.date(),
check_out: z.date(),
guests: z.number().int().positive(),
rooms: z.number().int().positive(),
}).refine(
data => data.check_out > data.check_in,
{
message: "Check-out must be after check-in",
path: ['check_out'],
}
).refine(
data => data.guests <= data.rooms * 2,
{
message: "Not enough rooms for guests (max 2 per room)",
path: ['guests'],
}
);
Array Validation
const orderSchema = z.object({
items: z.array(
z.object({
product_id: z.number().int().positive(),
quantity: z.number().int().positive(),
price: z.number().positive(),
})
)
.min(1, "Order must have at least one item")
.max(50, "Order cannot have more than 50 items"),
tags: z.array(z.string())
.optional(),
// Unique array
categories: z.array(z.string())
.refine(
arr => new Set(arr).size === arr.length,
"Categories must be unique"
),
});
Nested Objects
const userProfileSchema = z.object({
user: z.object({
name: z.string(),
email: z.string().email(),
}),
address: z.object({
street: z.string(),
city: z.string(),
state: z.string().length(2, "State must be 2 letters"),
zip: z.string().regex(/^\d{5}(-\d{4})?$/),
}).optional(),
preferences: z.object({
theme: z.enum(['light', 'dark']),
language: z.string().length(2),
notifications: z.boolean(),
}),
});
Using Validation in Routes
Basic Usage
import { z } from 'zod';
const createProductSchema = z.object({
name: z.string().min(1).max(255),
price: z.number().positive(),
stock: z.number().int().nonnegative().optional(),
});
export default async function productsRoutes(fastify: FastifyInstance) {
fastify.post('/products', async (request, reply) => {
try {
// Validate request body
const validatedData = createProductSchema.parse(request.body);
// Create product with validated data
const product = await fastify.prisma.products.create({
data: validatedData,
});
return reply.status(201).send(product);
} catch (error: any) {
if (error.name === 'ZodError') {
return reply.status(400).send({
error: 'Validation failed',
issues: error.errors,
});
}
throw error;
}
});
}
Partial Updates (PATCH)
const updateProductSchema = createProductSchema.partial();
fastify.patch<{ Params: { id: string } }>(
'/products/:id',
async (request, reply) => {
const id = parseInt(request.params.id);
// Validate with partial schema (all fields optional)
const validatedData = updateProductSchema.parse(request.body);
const product = await fastify.prisma.products.update({
where: { id },
data: validatedData,
});
return product;
}
);
Query Parameters
const listProductsQuerySchema = z.object({
page: z.string()
.transform(val => parseInt(val))
.pipe(z.number().int().positive())
.default('1'),
limit: z.string()
.transform(val => parseInt(val))
.pipe(z.number().int().min(1).max(100))
.default('10'),
category: z.string().optional(),
sort: z.enum(['name', 'price', 'created_at'])
.default('created_at'),
order: z.enum(['asc', 'desc'])
.default('desc'),
});
fastify.get('/products', async (request, reply) => {
const params = listProductsQuerySchema.parse(request.query);
const products = await fastify.prisma.products.findMany({
where: params.category ? { category: params.category } : undefined,
orderBy: { [params.sort]: params.order },
skip: (params.page - 1) * params.limit,
take: params.limit,
});
return products;
});
Error Handling
Validation Error Response
When validation fails, Zod provides detailed error information:
Request:
curl -X POST http://localhost:3000/products \
-H "Content-Type: application/json" \
-d '{
"name": "",
"price": -10,
"stock": "invalid"
}'
Response:
{
"error": "Validation failed",
"issues": [
{
"code": "too_small",
"minimum": 1,
"type": "string",
"inclusive": true,
"exact": false,
"message": "Name is required",
"path": ["name"]
},
{
"code": "too_small",
"minimum": 0,
"type": "number",
"inclusive": false,
"exact": false,
"message": "Price must be positive",
"path": ["price"]
},
{
"code": "invalid_type",
"expected": "number",
"received": "string",
"message": "Expected number, received string",
"path": ["stock"]
}
]
}
Custom Error Messages
const schema = z.object({
email: z.string()
.email({ message: "Please enter a valid email address" }),
age: z.number()
.min(18, { message: "You must be at least 18 years old" })
.max(120, { message: "Please enter a valid age" }),
password: z.string()
.min(8, { message: "Password must be at least 8 characters long" })
.regex(/[A-Z]/, {
message: "Password must contain at least one uppercase letter"
}),
});
Formatting Error Responses
import { ZodError } from 'zod';
function formatZodError(error: ZodError) {
return {
error: 'Validation failed',
details: error.errors.map(err => ({
field: err.path.join('.'),
message: err.message,
code: err.code,
})),
};
}
// Usage in route
catch (error: any) {
if (error.name === 'ZodError') {
return reply.status(400).send(formatZodError(error));
}
}
Best Practices
DO ✅
1. Validate all user input:
// POST and PATCH routes
const data = schema.parse(request.body);
// Query parameters
const params = querySchema.parse(request.query);
// URL parameters (if complex)
const { id } = paramsSchema.parse(request.params);
2. Use specific error messages:
z.string()
.min(1, "Name is required") // ✅ Clear
.max(100, "Name too long") // ❌ Vague
z.string()
.min(1, "Name is required")
.max(100, "Name cannot exceed 100 characters") // ✅ Specific
3. Validate business rules:
const orderSchema = z.object({
quantity: z.number().int().positive(),
unit_price: z.number().positive(),
discount: z.number().min(0).max(100),
}).refine(
data => {
const total = data.quantity * data.unit_price;
const discountAmount = total * (data.discount / 100);
return discountAmount <= 1000; // Max $1000 discount
},
"Discount cannot exceed $1000"
);
4. Use transform for type coercion:
// Query params are always strings
const querySchema = z.object({
page: z.string()
.transform(val => parseInt(val, 10))
.pipe(z.number().int().positive()),
});
DON’T ❌
1. Skip validation:
// ❌ Bad - no validation
const data = request.body as any;
// ✅ Good - always validate
const data = schema.parse(request.body);
2. Use loose validation:
// ❌ Bad - accepts empty strings
const schema = z.object({
name: z.string(),
});
// ✅ Good - enforces non-empty
const schema = z.object({
name: z.string().min(1),
});
3. Ignore validation errors:
// ❌ Bad - silent failure
try {
const data = schema.parse(request.body);
} catch {
// Continue anyway
}
// ✅ Good - return error to client
catch (error: any) {
if (error.name === 'ZodError') {
return reply.status(400).send({
error: 'Validation failed',
issues: error.errors,
});
}
}
Testing Validation
Unit Tests
import { describe, it, expect } from 'vitest';
describe('Product Schema', () => {
it('accepts valid product', () => {
const valid = {
name: 'Laptop',
price: 999.99,
stock: 10,
};
expect(() => productSchema.parse(valid)).not.toThrow();
});
it('rejects empty name', () => {
const invalid = {
name: '',
price: 999.99,
};
expect(() => productSchema.parse(invalid)).toThrow('Name is required');
});
it('rejects negative price', () => {
const invalid = {
name: 'Laptop',
price: -10,
};
expect(() => productSchema.parse(invalid)).toThrow('Price must be positive');
});
});
Integration Tests
# Test validation with cURL
curl -X POST http://localhost:3000/products \
-H "Content-Type: application/json" \
-d '{"name": "", "price": -10}' \
-w "\n%{http_code}\n"
# Should return 400
curl -X POST http://localhost:3000/products \
-H "Content-Type: application/json" \
-d '{"name": "Valid Product", "price": 99.99}' \
-w "\n%{http_code}\n"
# Should return 201
Common Patterns
Email + Password Registration
const registerSchema = z.object({
email: z.string()
.email("Invalid email address")
.toLowerCase(),
password: z.string()
.min(8, "Password must be at least 8 characters")
.regex(/[A-Z]/, "Must contain uppercase letter")
.regex(/[a-z]/, "Must contain lowercase letter")
.regex(/[0-9]/, "Must contain number")
.regex(/[^A-Za-z0-9]/, "Must contain special character"),
confirm_password: z.string(),
}).refine(
data => data.password === data.confirm_password,
{
message: "Passwords don't match",
path: ['confirm_password'],
}
);
Pagination
const paginationSchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(100).default(10),
sort: z.string().optional(),
order: z.enum(['asc', 'desc']).default('desc'),
});
File Upload Metadata
const fileUploadSchema = z.object({
filename: z.string()
.regex(/^[\w\-. ]+$/, "Invalid filename"),
mimetype: z.enum([
'image/jpeg',
'image/png',
'image/gif',
'application/pdf',
]),
size: z.number()
.int()
.positive()
.max(10 * 1024 * 1024, "File too large (max 10MB)"),
});
Additional Resources
Was this page helpful?
Thank you for your feedback!