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 TypeZod TypeExample
Stringz.string()name: z.string()
Intz.number().int()age: z.number().int()
Float/Decimalz.number()price: z.number()
Booleanz.boolean()is_active: z.boolean()
DateTimez.date() or z.string().datetime()created_at: z.date()
String? (optional)z.string().optional()bio: z.string().optional()
Enumz.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?