Skip to content

Zod Validation: The Runtime Types I Stopped Hand-Checking

Zod Validation: The Runtime Types I Stopped Hand-Checking

Confession: for years I wrote TypeScript like the data coming into my app would always match the types I’d declared. I’d type an API response as User, cast the JSON, and move on with my day feeling very safe. Then a backend changed a field from a number to a string, my “fully typed” code sailed straight past it, and I spent an afternoon chasing a crash that TypeScript had cheerfully promised me was impossible.

The thing nobody tells you early on is that TypeScript types vanish at runtime. They’re a compile-time story. The moment real data shows up from a form, an API, or a file, your beautiful types are gone and you’re back to hoping. Zod is the tool that finally fixed this for me, and I went from sprinkling it in one or two spots to reaching for it any time data crosses a boundary. Here’s how I actually use it, and where I hold back.

The lie I was telling with as

Here’s the pattern I used to write, and it’s the pattern I see in most codebases that “use TypeScript properly”:

type User = { id: number; name: string; email: string };

async function getUser(id: number): Promise<User> {
  const res = await fetch(`/api/users/${id}`);
  return (await res.json()) as User;  // a promise I cannot keep
}

That as User is me telling the compiler to trust me. The compiler does, because that’s what as means: stop checking, I’ve got this. Except I don’t got this. The JSON could be missing email, could have id as a string, could be an error object with a completely different shape. TypeScript shrugs and types it User anyway, and the bug surfaces three function calls later where it’s much harder to trace. The official handbook is upfront that type assertions are an escape hatch, not a guarantee.

What Zod does instead

Zod lets you describe the shape once as a schema, then validate real data against it at runtime. If the data fits, you get a typed value back. If it doesn’t, you get a clear error instead of a silent lie.

import { z } from "zod";

const User = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
});

async function getUser(id: number) {
  const res = await fetch(`/api/users/${id}`);
  return User.parse(await res.json());  // throws loudly if the shape is wrong
}

Now if the backend sends id as a string, parse throws right at the boundary, with a message telling you which field was wrong and why. The error lands where the bad data entered, not three layers deep. The full API is documented at zod.dev, and it’s one of those docs sites where reading the first page genuinely changes how you write code.

The part that sold me: types for free

I assumed Zod meant maintaining two things, a schema and a TypeScript type, kept in sync by hand. That sounded miserable. It turns out you write the schema and let Zod hand you the type:

const User = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
});

type User = z.infer<typeof User>;
// { id: number; name: string; email: string }

One source of truth. Change the schema and the type updates automatically, because the type is derived from the schema rather than declared alongside it. This is the same instinct I wrote about in my post on TypeScript utility types: let the compiler derive things instead of copying them by hand. Zod just extends that idea past the compile-time wall.

Forms are where it pays rent

API responses are the obvious case. Forms are where Zod quietly saves me the most time, because form data is the messiest data in any app. Everything arrives as a string, half of it is optional, and users will type things you didn’t plan for.

const Signup = z.object({
  email: z.string().email(),
  age: z.coerce.number().min(18, "Must be 18 or older"),
  website: z.string().url().optional(),
});

const result = Signup.safeParse(formData);
if (!result.success) {
  return result.error.flatten().fieldErrors;  // ready to render next to inputs
}
const clean = result.data;  // fully typed and trustworthy

Two things to notice. z.coerce.number() turns the string "21" into the number 21 before validating, which handles the everything-is-a-string problem. And safeParse returns a result object instead of throwing, which is what you want in a form handler where an error is a normal outcome rather than a crash. I lean on this hard in the form-heavy work I describe over on my portfolio, where validation rules tend to multiply faster than anyone expects.

parse vs safeParse, and not overdoing it

I get asked which to use. My rule is simple. Use parse when bad data means something is genuinely broken and crashing loudly is correct, like an API response that violates a contract. Use safeParse when bad data is expected and you want to handle it gracefully, like user input. Reaching for try/catch around parse in a form handler is a sign you wanted safeParse all along.

The other thing I had to learn is restraint. Zod has a runtime cost, since it actually walks your data and checks it. That cost is trivial for an API response or a form submit. It is not trivial if you validate a 50,000-row dataset inside a tight loop, and I’ve watched someone do exactly that and then blame the library. Validate at the edges, where data enters your system, and trust your own types in the interior once it’s clean. That single habit is what makes Zod feel free in practice even though it isn’t literally free.

Where I still don’t reach for it

I don’t wrap internal function calls in schemas. If both sides of a call are my own typed code that never touched the outside world, TypeScript already has me covered and adding Zod there is just noise. I also don’t use it for config I control and ship myself, though I do use it for config that users or other services provide. The mental model that’s served me well: Zod guards the border between the typed world I control and the untyped world I don’t. Inside the border, plain TypeScript. At the border, a schema.

What to try this week

Find the one fetch in your codebase whose response you trust the least, probably a third-party API or an internal service that changes often. Write a Zod schema for the response you expect, swap your as SomeType for a .parse(), and derive the type with z.infer so you delete the hand-written interface. Then point it at the real endpoint and see what happens. Either it passes and you’ve added a real guarantee for about six lines of code, or it throws and you just found a shape mismatch you didn’t know you had. Both outcomes are a win, and both took ten minutes.