ADR-0013: Zod for Runtime Validation
Status
Accepted - 2025-01-26
Context
TVL Platform receives data from multiple sources (HTTP requests, webhooks, external APIs, user input) that must be validated at runtime. We need a validation library that:
Business Requirements
- Prevent invalid data from entering the system
- Provide clear error messages to users
- Support complex business rules (date ranges, pricing constraints)
Technical Requirements
- TypeScript integration (type inference)
- Runtime validation (catch errors early)
- Composable schemas (reuse validation logic)
- Parse and transform data (e.g., string → Date)
- Generate TypeScript types from schemas
Constraints
- TypeScript 5.3+ (ADR-0005)
- Must work with Fastify (ADR-0022)
- Must validate external API responses (Hostaway, Stripe)
- Frontend also needs validation (React Hook Form)
Decision
Zod for runtime validation and type inference across frontend and backend.
Example Usage
import { z } from 'zod';
// Define schema
const BookingSchema = z.object({
  guestName: z.string().min(1).max(255),
  checkIn: z.string().datetime(),
  checkOut: z.string().datetime(),
  totalCents: z.number().int().positive(),
  status: z.enum(['pending', 'confirmed', 'cancelled'])
});
// Infer TypeScript type from schema
type Booking = z.infer<typeof BookingSchema>;
// Validate data
const result = BookingSchema.safeParse(data);
if (!result.success) {
  console.error(result.error.issues);
} else {
  const booking: Booking = result.data;
}
Rationale
- Type Inference: TypeScript types generated from schemas (DRY)
- Composability: Schemas can be combined and extended
- Error Messages: Clear, actionable error messages
- Transformations: Parse strings to dates, trim whitespace, etc.
- Zero Dependencies: Small bundle size (~8KB minified)
Alternatives Considered
Alternative 1: Yup
Rejected
Pros:
- Popular (especially with Formik)
- Good error messages
- Async validation support
Cons:
- Poor Type Inference: Must manually write TypeScript types
- Larger Bundle: ~15KB (vs. Zod 8KB)
- Less Active: Maintenance has slowed
- Verbose API: More code to write
Decision: Zod better type inference and smaller bundle.
Alternative 2: Joi
Rejected
Pros:
- Very popular (especially in Node.js)
- Feature-rich (many validators)
- Good error messages
Cons:
- No Type Inference: Must manually write TypeScript types
- Large Bundle: ~100KB (too heavy for frontend)
- Server-Only: Not suitable for frontend
- Verbose API: Complex schemas are hard to read
Decision: Zod lighter and works on frontend + backend.
Alternative 3: class-validator
Rejected
Pros:
- Used with NestJS
- Decorator-based (concise)
Cons:
- Experimental Decorators: Requires experimental TypeScript feature
- Class-Based: Forces OOP (we prefer functional)
- No Parsing: Only validation (no transformations)
- Reflection Required: Adds runtime overhead
Decision: Zod functional approach better fits our stack.
Alternative 4: TypeScript Only (No Runtime Validation)
Rejected
Pros:
- No external library
- Compile-time checks
Cons:
- No Runtime Safety: TypeScript erased at runtime
- Unsafe External Data: Cannot validate API responses, user input
- Security Risk: SQL injection, XSS vulnerabilities
- No Parsing: Cannot transform data
Decision: Runtime validation essential for security.
Consequences
Positive
- 
Type Safety - TypeScript types inferred from schemas (single source of truth)
- Refactoring safe (change schema → compiler errors)
- No drift between types and validation
 
- 
Developer Experience - Concise API (easy to read and write)
- Great error messages (helpful for debugging)
- Autocomplete works (TypeScript IntelliSense)
- Fast feedback (TypeScript compiler)
 
- 
Security - Prevents invalid data from entering system
- SQL injection prevention (validate input before queries)
- XSS prevention (sanitize user input)
- Clear error messages (don't expose internals)
 
- 
Code Reuse - Schemas shared between frontend and backend
- Compose schemas (DRY principle)
- Extend existing schemas (inheritance)
 
- 
Parsing & Transformation - Coerce types (z.coerce.number()parses "123" → 123)
- Transform data (z.string().trim(),z.string().email())
- Default values (z.string().default("unknown"))
 
- Coerce types (
Negative
- 
Bundle Size - Adds ~8KB to frontend bundle
- Mitigation: Tree-shakeable, only import what you use
 
- 
Learning Curve - Developers must learn Zod API
- Mitigation: Simple API, similar to TypeScript types
 
- 
Runtime Overhead - Validation adds ~1-5ms per request
- Mitigation: Negligible for most use cases, cache schemas
 
- 
Error Messages in Production - Detailed error messages leak schema structure
- Mitigation: Transform errors before sending to client
 
Common Patterns
1. API Request Validation
// schemas/booking.ts
export const CreateBookingSchema = z.object({
  guestName: z.string().min(1).max(255),
  checkIn: z.string().datetime(),
  checkOut: z.string().datetime(),
  totalCents: z.number().int().positive()
});
// api/routes/bookings.ts
app.post('/bookings', async (req, reply) => {
  const result = CreateBookingSchema.safeParse(req.body);
  if (!result.success) {
    return reply.status(400).send({
      error: 'Validation failed',
      issues: result.error.issues
    });
  }
  const booking = await createBooking(result.data);
  return reply.status(201).send({ booking });
});
2. External API Response Validation
// Validate Hostaway API response
const HostawayListingSchema = z.object({
  id: z.number(),
  name: z.string(),
  address: z.string(),
  bedrooms: z.number().int().min(0),
  amenities: z.array(z.string())
});
const response = await fetch('https://api.hostaway.com/listings');
const data = await response.json();
const result = HostawayListingSchema.safeParse(data);
if (!result.success) {
  throw new Error(`Invalid Hostaway response: ${result.error.message}`);
}
const listing = result.data; // Fully typed
3. Schema Composition
// Base schema
const BaseEntitySchema = z.object({
  id: z.string().uuid(),
  createdAt: z.string().datetime(),
  updatedAt: z.string().datetime()
});
// Extend base schema
const BookingSchema = BaseEntitySchema.extend({
  guestName: z.string(),
  checkIn: z.string().datetime(),
  checkOut: z.string().datetime()
});
// Pick specific fields
const BookingListItemSchema = BookingSchema.pick({
  id: true,
  guestName: true,
  checkIn: true
});
4. Custom Validation
const BookingSchema = z.object({
  checkIn: z.string().datetime(),
  checkOut: z.string().datetime()
}).refine(data => new Date(data.checkOut) > new Date(data.checkIn), {
  message: 'Check-out must be after check-in',
  path: ['checkOut']
});
5. Coercion & Transformation
const QueryParamsSchema = z.object({
  page: z.coerce.number().int().min(1).default(1),     // "5" → 5
  limit: z.coerce.number().int().min(1).max(100).default(20),
  search: z.string().trim().optional()                 // " hello " → "hello"
});
// Parse URL query params
const params = QueryParamsSchema.parse(req.query);
console.log(params.page); // number (not string)
Frontend Integration
React Hook Form
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
const CreateBookingSchema = z.object({
  guestName: z.string().min(1, 'Guest name is required'),
  checkIn: z.string().datetime(),
  checkOut: z.string().datetime()
});
function BookingForm() {
  const {
    register,
    handleSubmit,
    formState: { errors }
  } = useForm({
    resolver: zodResolver(CreateBookingSchema)
  });
  const onSubmit = (data) => {
    // data is validated and typed
    console.log(data.guestName); // ✅ string
  };
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('guestName')} />
      {errors.guestName && <span>{errors.guestName.message}</span>}
    </form>
  );
}
Fastify Integration
import { FastifyPluginAsync } from 'fastify';
import { z } from 'zod';
const bookingRoutes: FastifyPluginAsync = async (app) => {
  app.post('/bookings', {
    schema: {
      body: CreateBookingSchema,
      response: {
        201: BookingSchema
      }
    }
  }, async (req, reply) => {
    // req.body is validated and typed
    const booking = await createBooking(req.body);
    return reply.status(201).send(booking);
  });
};
Error Handling
Transform Errors for API Response
function formatZodError(error: z.ZodError) {
  return {
    type: 'validation_error',
    errors: error.issues.map(issue => ({
      field: issue.path.join('.'),
      message: issue.message,
      code: issue.code
    }))
  };
}
// Usage
const result = BookingSchema.safeParse(data);
if (!result.success) {
  return reply.status(400).send(formatZodError(result.error));
}
Example Error Response
{
  "type": "validation_error",
  "errors": [
    {
      "field": "guestName",
      "message": "String must contain at least 1 character(s)",
      "code": "too_small"
    },
    {
      "field": "totalCents",
      "message": "Number must be greater than 0",
      "code": "too_small"
    }
  ]
}
Best Practices
1. Separate Schemas by Use Case
// Don't use same schema for create and update
const CreateBookingSchema = z.object({
  guestName: z.string(),
  checkIn: z.string().datetime(),
  checkOut: z.string().datetime(),
  totalCents: z.number()
});
const UpdateBookingSchema = CreateBookingSchema.partial(); // All fields optional
const BookingResponseSchema = CreateBookingSchema.extend({
  id: z.string().uuid(),
  status: z.enum(['pending', 'confirmed', 'cancelled']),
  createdAt: z.string().datetime()
});
2. Use .strict() for API Requests
// Reject unknown fields
const BookingSchema = z.object({
  guestName: z.string()
}).strict();
// ❌ Fails: { guestName: "John", age: 30 }
// ✅ Passes: { guestName: "John" }
3. Cache Schemas
// Cache compiled schemas (avoid re-parsing)
const bookingSchemaCache = BookingSchema;
// Use cached schema
bookingSchemaCache.parse(data);
4. Validate at Boundaries
// ✅ Good - validate at API boundary
app.post('/bookings', (req, reply) => {
  const data = BookingSchema.parse(req.body);
  await createBooking(data); // data is trusted
});
// ❌ Bad - validate deep in business logic
function createBooking(data: unknown) {
  const validated = BookingSchema.parse(data); // Too late!
}
Validation Checklist
- All API endpoints validate input
- All external API responses validated
- Schemas shared between frontend and backend
- Custom error formatting implemented
- Zod used in React Hook Form
-  Schemas use .strict()for API requests
- TypeScript types inferred (no manual types)
- Business rules validated (custom refinements)