Type-Safe From Database to UI: Zod Contracts in a Turborepo
The bug that wastes the most time is the silent one where the API returns a slightly different shape than the frontend expects. Here is how a single Zod contracts package in a Turborepo kills that class of bug entirely.

On this page
The most expensive bugs I have shipped were not crashes — they were shape mismatches. The API renamed a field, the frontend still read the old one, TypeScript was happy because both sides hand-wrote their own types, and the bug surfaced in production as undefined. The fix is structural: there should only ever be one definition of a shape, and everything else should derive from it. This portfolio is built that way.
One package owns every shape
In the Turborepo, a @portfolio/contracts package holds Zod schemas for every entity and request. Types are inferred, never written twice:
import { z } from 'zod';
export const recommendationSchema = z.object({
id: z.string().uuid(),
authorName: z.string(),
body: z.string(),
status: z.enum(['published', 'hidden', 'pending']),
});
export type Recommendation = z.infer<typeof recommendationSchema>;The backend validates with the same schema
On the NestJS side, that schema becomes a DTO through nestjs-zod — so the runtime validation and the static type can never disagree:
class CreateRecommendationDto extends createZodDto(createRecommendationRequestSchema) {}
@Post()
create(@Body() body: CreateRecommendationDto) {
return this.recommendations.create(body); // body is fully typed + validated
}If a request does not match, the pipe rejects it with a structured error before a single line of business logic runs. No manual if (!body.x) throw checks.
The frontend consumes the same types
A small typed api-client package imports those same inferred types, so the Next.js app — both React Server Components and browser islands — gets autocomplete and compile-time checking for every endpoint:
const recs = await api.recommendations.list(); // Recommendation[]
recs.map((r) => r.authorName); // ✓ r.author ✗ compile errorWhy the monorepo matters
This only works because the API and the web apps live in one repo sharing one package. Change recommendationSchema, and bun run type-check immediately fails in every app that reads the renamed field — before you ever open a browser. The refactor that used to be archaeology becomes a compiler to-do list.
The payoff
- Refactors are safe — rename a field once, fix wherever the compiler points.
- Validation and types are the same thing — no drift between what you check and what you type.
- Onboarding is faster — the contracts package is a readable map of the whole system.
End-to-end type safety is not about ceremony or generics gymnastics. It is one boring discipline: define each shape once, derive everything else. Do that, and an entire category of production bugs simply stops happening.
Comments
Loading comments…