Skip to content
~/mahadi hassan
← All posts

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.

2 min read
Type-Safe From Database to UI: Zod Contracts in a Turborepo — cover
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 error

Why 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…