Skip to content
← All posts

Web DevelopmentBy 08 Jul 20263 min read

End-to-End Type Safety with TypeScript and Zod

TypeScript's types disappear at runtime — exactly where untrusted data enters your app. How to use Zod schemas as a single source of truth for validation and types across the stack.

End-to-End Type Safety with TypeScript and Zod

TypeScript catches a huge class of bugs at compile time. But its types vanish when the code runs — and that's precisely where the riskiest data arrives: request bodies, form submissions, environment variables, third-party APIs and AI model outputs. Runtime validation closes that gap, and Zod has become the most popular way to do it in TypeScript.

One schema, validated at every boundaryDefine the shape once; derive types and runtime validation from it.

The Problem With as

const body = (await req.json()) as CreateStudentInput; // a promise, not a check

A type assertion tells the compiler to trust you. If the client sends { "age": "twelve" }, your code carries on with the wrong data until something breaks far away from the cause.

Schemas as the Single Source of Truth

With Zod, you define the shape once and derive both the runtime validator and the TypeScript type:

import { z } from "zod";

export const CreateStudent = z.object({
  name: z.string().trim().min(2).max(100),
  email: z.string().email(),
  classId: z.string().uuid(),
  dateOfBirth: z.coerce.date(),
});

export type CreateStudentInput = z.infer<typeof CreateStudent>;

On the server, parse every untrusted input:

const result = CreateStudent.safeParse(await req.json());
if (!result.success) {
  return Response.json({ error: result.error.flatten() }, { status: 400 });
}
const student = await createStudent(result.data); // fully typed and validated

Reuse the Same Schema Everywhere

  • Forms — libraries such as React Hook Form accept Zod resolvers, so client-side errors match server rules exactly.
  • Server Actions and API routes — parse inputs before any business logic.
  • Environment variables — validate process.env at startup so a missing secret fails the deploy, not a customer request.
  • External APIs and AI outputs — validate responses from third parties and structured outputs from language models before trusting them.
const Env = z.object({
  DATABASE_URL: z.string().url(),
  OPENROUTER_API_KEY: z.string().min(10),
  NODE_ENV: z.enum(["development", "production", "test"]),
});
export const env = Env.parse(process.env);

End-to-End Types Across the Network

When the frontend and backend share a TypeScript codebase, types can flow across the network boundary. Tools such as tRPC, Next.js Server Actions and OpenAPI generators let the client know exactly what each endpoint accepts and returns, so a backend change that breaks the frontend fails at compile time.

Practical Tips

  • Validate at the edges, trust inside. Parse once at the boundary; internal functions can then rely on types.
  • Prefer safeParse in request handlers to return clean 400 errors instead of throwing.
  • Keep schemas close to the domain and share them between client and server.
  • Transform while parsing — trim strings, coerce dates, normalise emails — so downstream code receives clean data.

Key Takeaways

  • TypeScript types don't exist at runtime; untrusted input needs runtime validation.
  • Define Zod schemas once and derive types with z.infer.
  • Validate forms, requests, environment variables, third-party responses and AI outputs.
  • Share schemas across client and server for genuine end-to-end type safety.

Sources

More posts