Zod vs types: when the runtime cost earns its keep
TypeScript types vanish at compile time. Zod schemas do not — they
are real JavaScript objects that stick around in your bundle and run every
time you call .parse(). That is not a flaw; it is the whole
point. But it is a cost, and the question is whether the cost earns its
keep in your specific code path.
Let us start with the numbers. Zod v3 is about 14 KB gzipped as a dependency. Each schema you define adds more: a typical API response schema runs another 200–500 bytes, and parsing spends measurable CPU — tens to hundreds of microseconds per parse for realistic payloads. On a Node server hitting a thousand requests a second, that is 10–100 ms/second of pure validation CPU. On the browser, maybe a millisecond of jank per navigation that parses an API response.
None of that is catastrophic. But it is not free either. And for code paths where you control both ends — a helper function inside your app that takes a known internal shape — you are paying for insurance on a risk that does not exist.
Runtime validation is insurance. Like insurance, the cost is visible and the upside happens only when you needed it.
— the house styleWhere are your trust boundaries?
The question “when does Zod earn it” reduces to one about trust boundaries. Here is a map of common positions.
Definitely worth it — network boundaries
Where an external system’s contract can drift without warning. Zod catches the drift at the parse site, not ten frames deep in business logic.
const Customer = z.object({
id: z.string(),
email: z.string().email().nullable(),
});
const data = Customer.parse(await fetch('/api/customer').then(r => r.json()));
Stripe is unlikely to break a contract, but their smaller peers do it all
the time. The .parse() call throws with a useful message when
the shape changes, and the error lands at the boundary.
Usually worth it — user input
Form submissions, URL query params, WebSocket messages.
const SearchParams = z.object({
q: z.string().min(1).max(100),
page: z.coerce.number().int().min(1).default(1),
});
const { q, page } = SearchParams.parse(Object.fromEntries(url.searchParams));
The z.coerce.number() here earns Zod’s place by itself
— query params arrive as strings, and the one-line coercion beats a
helper function.
Not worth it — internal utilities
Where the caller’s type checker already guarantees the shape. Do not do this:
// The input comes from your own code. TS has already checked.
const UserIdInput = z.object({ userId: z.string() });
function getUser(input: z.infer<typeof UserIdInput>) { ... }
// Do this instead.
interface GetUserInput { userId: string; }
function getUser(input: GetUserInput) { ... } Every Zod schema you add to a purely internal path is runtime cost paying for a compile-time problem that is already solved.
Depends — everything else
Database results, file reads, third-party libraries that claim to return
a typed value but ship their types as lies. If your ORM’s generated
types are trustworthy (Prisma, Drizzle), no Zod needed. If your ORM
returns any or stale types, add Zod at the boundary. Same for
parsing a JSON config file from disk: validate it.
Two patterns that make Zod cheaper
Share types via z.infer. Don’t write the schema and a parallel interface:
const User = z.object({ id: z.string(), name: z.string() });
type User = z.infer<typeof User>; // type and schema stay in sync forever Parse at the edge, not inline. One call at the boundary beats ten scattered through the call graph:
// parse once, then trust the type
const customer = Customer.parse(responseJson);
renderProfile(customer);
syncToCache(customer);
logActivity(customer);
// don't re-validate at every function Alternatives worth knowing
- io-ts — similar ideas, more functional style. Smaller API surface, bigger learning curve. For serious FP codebases.
- Typebox — JSON Schema native. Smaller runtime, less elegant API. If you already have JSON Schema as a source of truth.
- Valibot — modular rewrite of Zod’s ideas. Tree-shakeable, 2–5 KB bundle vs Zod’s 14. For hard bundle-size constraints.
- Just
JSON.parse— honestly fine for some paths. A one-off internal script does not need a schema.
The TypeScript-type output from this tool is the right output when you do not need runtime validation. The Zod output is the right output when you do. Picking correctly is a code-path-by-code-path question, not a project-wide decision. Both can live in the same codebase. Pick Zod where the wild data enters, pick types where you are shuffling shapes you already own.