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.