3.9 KiB
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?
-
Single source of truth: Schema defines both TypeScript type and runtime validation. No drift possible.
-
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)
-
Runtime safety: TypeScript only validates at compile time. Zod validates at runtime for external data (API responses, user input).
-
Better DX:
- Composable schemas (
z.extend(),z.pick(),z.omit()) - Excellent error messages
- Full TypeScript inference
- Composable schemas (
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),
});