Assist_Design/docs/decisions/002-zod-first-validation.md

3.9 KiB

ADR-002: Zod-First Validation

Date: 2025-01-15 Status: Accepted

Context

The Customer Portal needs validation at multiple layers:

  • BFF request/response validation
  • Domain type validation
  • Portal form validation

Traditional approaches use separate validation at each layer (class-validator in NestJS, yup/joi in React, manual TypeScript types). This leads to:

  • Duplicated validation logic
  • Type/validation drift
  • Inconsistent error messages

Decision

Use Zod schemas as the single source of truth for both TypeScript types and runtime validation across all layers.

// Schema defines both type AND validation
export const invoiceSchema = z.object({
  id: z.number(),
  status: z.enum(["paid", "unpaid", "overdue"]),
  amount: z.number().positive(),
});

// Type derived from schema - always in sync
export type Invoice = z.infer<typeof invoiceSchema>;

Rationale

Why Zod?

  1. Single source of truth: Schema defines both TypeScript type and runtime validation. No drift possible.

  2. Cross-layer consistency: Same schema works in:

    • Domain layer (type definitions)
    • BFF (request/response validation via nestjs-zod)
    • Portal (form validation via @hookform/resolvers/zod)
  3. Runtime safety: TypeScript only validates at compile time. Zod validates at runtime for external data (API responses, user input).

  4. Better DX:

    • Composable schemas (z.extend(), z.pick(), z.omit())
    • Excellent error messages
    • Full TypeScript inference

Alternatives Considered

Approach Pros Cons
class-validator + class-transformer NestJS native, decorators Types separate from validation, verbose
TypeScript only No runtime overhead No runtime validation, unsafe for external data
Joi/Yup Mature libraries Poor TypeScript inference, separate type definitions
Zod Types from schemas, excellent TS Slightly newer ecosystem

Consequences

Positive

  • Types and validation always in sync
  • Consistent validation across BFF and Portal
  • Excellent TypeScript inference
  • Composable schemas reduce duplication
  • Runtime safety for external data

Negative

  • Slightly larger bundle size vs TypeScript-only
  • Team needs to learn Zod API
  • Some NestJS features require createZodDto() wrapper

Implementation

BFF Validation

// apps/bff/src/app.module.ts
providers: [
  { provide: APP_PIPE, useClass: ZodValidationPipe },
]

// Controller uses DTOs built from domain schemas
import { createZodDto } from "nestjs-zod";
import { invoiceQuerySchema } from "@customer-portal/domain/billing";

class InvoiceQueryDto extends createZodDto(invoiceQuerySchema) {}

@Get()
async getInvoices(@Query() query: InvoiceQueryDto) { ... }

Domain Mappers

// packages/domain/billing/providers/whmcs/mapper.ts
export function transformWhmcsInvoice(raw: unknown): Invoice {
  const validated = whmcsInvoiceRawSchema.parse(raw); // Validate input
  const result = {
    /* transform */
  };
  return invoiceSchema.parse(result); // Validate output
}

Portal Forms

// apps/portal/src/features/auth/components/LoginForm.tsx
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { loginSchema } from "@customer-portal/domain/auth";

const form = useForm({
  resolver: zodResolver(loginSchema),
});