Skip to content

Spec-DD with Zod

Cross-boundary types live as Zod schemas, never as TypeScript interfaces.

Every cross-boundary type is a Zod schema. Types are derived (z.infer<typeof X>), never authored alongside the schema.

The rule

A “cross-boundary” type is anything that crosses:

  • A network boundary (API request/response)
  • A storage boundary (localStorage, IndexedDB, cookies)
  • A worker boundary (postMessage payloads)
  • A form boundary (user input fields)

For these, interface Foo { … } is forbidden. Use Zod and derive:

// src/schemas/feedback.ts
import { z } from 'zod';

export const FeedbackSchema = z.object({
  title: z.string().min(1),
  body: z.string().optional(),
  labels: z.array(z.string()).default([]),
});

// Derived type — no separate interface
export type Feedback = z.infer<typeof FeedbackSchema>;

Where schemas live

  • src/schemas/ — one file per domain object
  • Forms: already Zod-backed via react-hook-form + @hookform/resolvers/zod
  • Tests assert against schemas: expect(FeedbackSchema.safeParse(input).success).toBe(true)
  • The backend archetype (server-node/) uses @hono/zod-openapi to produce OpenAPI specs from the same schemas → typed client + runtime validation share one source

Why not just TypeScript interfaces?

TypeScript interfaces evaporate at runtime. Zod schemas are runtime validators — invalid data from the network is caught before it reaches application code, not after a cryptic undefined is not a function crash.

Full rationale → docs/PRINCIPLES.md §3

Report an issue

Show diagnostics

Environment

 

Console errors

 

Failed requests