Skip to main content

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

  1. Type Inference: TypeScript types generated from schemas (DRY)
  2. Composability: Schemas can be combined and extended
  3. Error Messages: Clear, actionable error messages
  4. Transformations: Parse strings to dates, trim whitespace, etc.
  5. 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

  1. Type Safety

    • TypeScript types inferred from schemas (single source of truth)
    • Refactoring safe (change schema → compiler errors)
    • No drift between types and validation
  2. Developer Experience

    • Concise API (easy to read and write)
    • Great error messages (helpful for debugging)
    • Autocomplete works (TypeScript IntelliSense)
    • Fast feedback (TypeScript compiler)
  3. 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)
  4. Code Reuse

    • Schemas shared between frontend and backend
    • Compose schemas (DRY principle)
    • Extend existing schemas (inheritance)
  5. Parsing & Transformation

    • Coerce types (z.coerce.number() parses "123" → 123)
    • Transform data (z.string().trim(), z.string().email())
    • Default values (z.string().default("unknown"))

Negative

  1. Bundle Size

    • Adds ~8KB to frontend bundle
    • Mitigation: Tree-shakeable, only import what you use
  2. Learning Curve

    • Developers must learn Zod API
    • Mitigation: Simple API, similar to TypeScript types
  3. Runtime Overhead

    • Validation adds ~1-5ms per request
    • Mitigation: Negligible for most use cases, cache schemas
  4. 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)

References