Skip to content
·4 min read

TypeScript Types Don't Exist at Runtime. Zod Fixes That.

Your API types are lies until you validate them. How I use Zod to make TypeScript types real, with schemas that generate the types instead of decorating them.

TypeScriptZodValidationWeb DevAPIs

Here is a sentence that saves careers: TypeScript types do not exist at runtime.

const user: User = await fetch("/api/me").then(r => r.json())

user is typed as User. Nothing verified that. If the API returns { nam: "..." } with a typo in the field, TypeScript shrugs and the app crashes four screens later when user.name is undefined. The annotation was a claim, not a check.

Step 1: Understand the gap types can't cover

TypeScript's type system is compile-time only. By the time your code runs, every type is gone. Anywhere data enters your program from outside (HTTP responses, forms, localStorage, env vars, third-party webhooks), you are trusting code you do not control to match your interface.

That trust is fine inside your own codebase. Across a network, it is how production bugs are born.

Step 2: Define schemas first, derive types second

Zod lets you describe data once and get both the validation and the type:

import { z } from "zod";
 
const UserSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(1),
  email: z.string().email(),
  role: z.enum(["admin", "member", "guest"]),
  createdAt: z.coerce.date(),
});
 
// The type is DERIVED, never hand-written
type User = z.infer<typeof UserSchema>;

This direction matters. If you write the type by hand and a schema separately, they drift. With z.infer, the schema is the single source of truth and drift is impossible by construction.

Step 3: Validate at every boundary

The pattern I apply everywhere:

// API client
async function getUser(id: string): Promise<User> {
  const res = await fetch(`/api/users/${id}`);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return UserSchema.parse(await res.json());
}

If the payload does not match, parse throws immediately with a precise error instead of letting corrupted data flow into your state. A malformed response fails at the fetch, not in a component three renders away.

The same pattern covers the other boundaries:

// Form input
const result = SignupSchema.safeParse(formData);
 
// Environment variables
const Env = z.object({
  DATABASE_URL: z.string().url(),
  API_KEY: z.string().min(1),
});
export const env = Env.parse(process.env);

That env check is my favorite line in any codebase. A missing variable now fails at boot with a message naming the variable, instead of undefined sneaking into production.

Step 4: Compose schemas instead of duplicating them

Real entities share fields. Zod's composition methods keep that honest:

const UserBase = z.object({
  id: z.string().uuid(),
  name: z.string().min(1),
  email: z.string().email(),
});
 
const CreateUserInput = UserBase.omit({ id: true });
const UpdateUserInput = UserBase.partial();
const AdminUser = UserBase.extend({
  permissions: z.array(z.string()),
});

When a field changes, it changes in one place. Compare that to the usual hand-written CreateUserDto, UpdateUserDto, UserResponse triple that never agrees with itself.

Discriminated unions, which TypeScript handles well at the type level, validate cleanly too:

const Notification = z.discriminatedUnion("kind", [
  z.object({ kind: z.literal("email"), to: z.string().email() }),
  z.object({ kind: z.literal("push"), deviceToken: z.string() }),
]);

Step 5: Make failures readable

parse throws; safeParse returns a result you can inspect:

const result = UserSchema.safeParse(payload);
 
if (!result.success) {
  console.error("Invalid user payload:", result.error.flatten().fieldErrors);
  // { email: [ 'Invalid email' ], role: [ 'Invalid enum value...' ] }
  throw new Response("Bad Request", { status: 400 });
}

flatten() turns the error tree into a per-field map. In logs, this is the difference between "payload rejected" and knowing the exact field, expected shape, and received value. When a partner sends you a broken webhook at 2am, this is what makes the fix a five-minute job.

The habit that ties it together

Rule of thumb I follow: assert types inside your app, parse types at its edges. Once a payload has crossed a validated boundary, cast freely and trust it. The schema paid for itself at the door.

The nice side effect: your schemas become documentation that cannot go stale. A new teammate reads UserSchema and knows exactly what a user is, what is optional, and what format each field has. No wiki page required.

Building a TypeScript app where API surprises keep reaching your UI? This is exactly the kind of hardening I do for clients. Tell me about your project and I will show you where the trust boundaries are.