A TypeScript interface is a promise the compiler checks against your code. It is not checked against the data your API actually returns. After tsc runs, every type annotation is erased — at runtime there is no User interface, just whatever JSON came down the wire. If the backend starts sending null where you expected a string, your code compiles, ships, and crashes in production.
Zod closes that gap. You describe the shape once as a schema, and Zod both validates the data at runtime and gives you a TypeScript type for free.
From response to schema
Say the endpoint returns:
{
"id": 1042,
"email": "ada@example.com",
"role": "admin",
"lastLogin": "2026-05-01T08:30:00Z",
"deletedAt": null
}A Zod schema that describes it honestly:
import { z } from "zod";
export const User = z.object({
id: z.number().int(),
email: z.string().email(),
role: z.enum(["admin", "editor", "viewer"]),
lastLogin: z.string().datetime(),
deletedAt: z.string().datetime().nullable(),
});
export type User = z.infer<typeof User>;Note z.infer: the TypeScript type is derived from the schema, so the two can never drift apart. You write the shape once.
Validate at the boundary
The point of Zod is to validate where untrusted data enters your app — right after fetch, not three layers deep. Use safeParse so a bad response is a value you handle, not an exception that escapes:
const res = await fetch("/api/users/1042");
const parsed = User.safeParse(await res.json());
if (!parsed.success) {
// parsed.error lists exactly which fields failed and why
console.error(parsed.error.issues);
throw new Error("User response did not match the contract");
}
const user = parsed.data; // fully typed AND verifiedWhy this catches drift
APIs change. A field that was always present becomes optional. An enum gains a fourth value. A numeric id becomes a string after a database migration. With types alone, none of these are visible until something breaks far from the cause. With a schema validated at the boundary, the failure happens immediately, names the exact field, and points at the response — not at the component that happened to read it.
When to reach for it
Not every fetch needs a schema. Reach for Zod when the data crosses a trust boundary you do not control: third-party APIs, webhooks, form submissions, localStorage you wrote in an older version, or any endpoint whose shape has burned you before. For an internal call you fully own and test, types may be enough.
Common mistakes
- Validating too late. A schema checked deep in a component defeats the purpose — parse at the fetch boundary so bad data never spreads.
- Reaching for
z.any(). It silences the type error and the runtime check at once. If you truly don't know a shape, usez.unknown()and narrow deliberately. - Duplicating the type. Hand-writing an
interfacenext to the schema means two sources of truth. Usez.infer. - Marking everything required. If one sample happened to include a field, that doesn't make it guaranteed. Use
.optional()and.nullable()where the API allows it.
Writing schemas by hand for a large response is tedious. Paste the payload into the PayloadIQ playgroundto generate a Zod schema and the matching TypeScript type, then tighten the enums and optionals the sample couldn't prove. The generated schema is a strong first draft — your job is to encode the rules the data alone can't reveal.