TypeScript at the Edge: Typing Your Whole Stack
From API routes to database rows to UI state — a single type system across the entire stack kills whole classes of bugs. Here's the setup that actually works without drowning you in boilerplate.
The most expensive bug in web development is the one that lives in the gap between two systems: the API returns a field renamed by a junior dev, the database column gets a new nullable constraint, the frontend ships a stale type. TypeScript can’t fix that — unless you stop typing each layer in isolation and start typing the contracts between them.
The one type that rules them all
Stop hand-writing API types. Define the schema once, in one place, and derive everything else from it:
import { z } from 'zod';
export const Article = z.object({
id: z.string().uuid(),
title: z.string().min(1).max(120),
pubDate: z.coerce.date(),
tags: z.array(z.string()).max(4),
featured: z.boolean().default(false),
});
export type Article = z.infer<typeof Article>;
This one schema now drives:
- Validation — every request body, every DB write, every file parsed.
- Types —
z.infergives you the type for free; no drift between the runtime check and the compile-time type. - OpenAPI docs —
zod-to-openapigenerates the spec, so the API documentation can never rot.
The rule: types are derived, never duplicated. Every time you write an interface that mirrors a schema, a bug is born.
Edge to edge: the full pipeline
Here’s the pipeline that has held up across several production apps:
- DB layer: Drizzle (or Prisma) generates types from the migration schema. This is the source of truth for persistence.
- Contract layer: Zod schemas for everything crossing a boundary — API routes, queue messages, webhook payloads, env vars.
z.enumfor the values you’d otherwise typo as strings. - Transport layer: tRPC or a typed RPC — when both sides share the schema package, calling a function on the server from the client is type-safe end to end. No more
fetch+as MyType+ prayer. - UI layer: React Query (or similar) infers the response type from the query definition. If the server schema changes, the client breaks at compile time, not in production at 2am.
Validating the edges your types can’t see
Types check your code — they don’t check reality. Data arrives from the outside world, and it lies:
- Never trust a parsed JSON blob. Validate it at the boundary with the schema, then convert to the typed value once. Validating once at the edge beats scattering
if (x !== undefined)checks through the app. - Env vars are runtime data. Parse
process.envthrough a schema at startup and fail fast — a missingDATABASE_URLshould crash the deploy, not the app at 3am. - API responses age. Pin the API version in the URL and run a contract test against the live endpoint. Types catch drift at build time; contract tests catch it when the other team redeploys.
The payoff, measured
A stack like this is not free — it’s maybe 10% more upfront code for the schemas. What you get back:
- Compile-time discovery of breaking API changes, instead of runtime errors.
- One place to change when a field renames.
npm run typecheckfinds every usage. - Self-documenting code — the schema is the documentation, and it can’t go stale.
- AI-assisted development gets safer — when your editor (or your agent) can see the exact contract, generated code stops guessing field names.
Start today, even on a legacy codebase
You don’t need a rewrite. Pick the worst boundary — usually the API client — and wrap it in a schema. Delete the hand-written interface, derive it from z.infer, and watch the diff noise disappear. Do the same for the next boundary, and the next.
A year from now, “the types were wrong” won’t be a thing your team says anymore. That’s the whole point.
Written by
Sten
Senior Editor
Builds AgenticOS and runs a homelab full of containers, GPUs and experiments.