Your TypeScript types are a lie the moment your code touches the outside world. That sounds dramatic, but it's the practical reality that makes Zod TypeScript validation worth learning. The compiler happily tells you a variable is a User with an email and an age, then the JSON coming back from an API has age as the string "null" and no email at all. TypeScript never checked. It couldn't. The types were erased before the request ever fired.
I hit this hard on a project where a third-party API "occasionally" returned numbers as strings. Occasionally meant about 3% of the time, which is exactly the frequency that survives QA and detonates in production. Zod fixed it in an afternoon. This post is what I wish someone had handed me before that afternoon.
Why runtime validation matters at all
TypeScript is a compile-time tool. When you run tsc, all the type annotations get stripped out and you're left with plain JavaScript. There is no User type at runtime. There is no check.
So anywhere data crosses into your program from a place TypeScript can't see, you have a trust boundary, and the type system is blind there:
-
API responses: you
fetch()and cast to a type, but the server can send anything. - Form input: users type strings, and they type weird ones.
-
Environment variables:
process.env.PORTisstring | undefined, always, no matter what you wish it were. -
localStorage, query params, message queues, webhooks: all untyped strings from beyond the wall.
A type assertion like const user = data as User does nothing at runtime. It's you promising the compiler, with zero evidence. Zod turns that promise into an actual check.
The core idea: one schema, two outputs
The thing that makes Zod click is that a schema is both a runtime validator and the source of your static type. You write the shape once. You get compile-time types and runtime guarantees from the same object.
import { z } from "zod";
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
isActive: z.boolean(),
});
// Derive the TypeScript type straight from the schema:
type User = z.infer<typeof UserSchema>;
// type User = { id: number; name: string; email: string; isActive: boolean }
z.infer<typeof UserSchema> reads the schema and produces the exact TypeScript type. Change the schema, the type changes with it. You never write the interface twice and you can never let them drift apart, which is the bug that kills hand-maintained types.
The primitives cover what you'd expect: z.string(), z.number(), z.boolean(), and for a fixed set of allowed values, z.enum(["draft", "published", "archived"]).
parse vs safeParse: pick the right one
This is the distinction people get wrong most often, so it's worth being precise.
parse validates and returns the typed value. If the data doesn't fit, it throws a ZodError.
try {
const user = UserSchema.parse(unknownData);
// user is fully typed as User here, guaranteed
console.log(user.email);
} catch (err) {
if (err instanceof z.ZodError) {
console.error(err.issues); // array of what went wrong
}
}
safeParse never throws. It returns a discriminated union, either { success: true, data } or { success: false, error }:
const result = UserSchema.safeParse(unknownData);
if (result.success) {
console.log(result.data.name); // typed, safe
} else {
console.log(result.error.issues); // ZodError, no exception thrown
}
My rule of thumb after a few years with it: use parse when a failure genuinely is a bug and crashing loudly is the correct response, like validating your own config at boot. Use safeParse when invalid data is an expected outcome you want to handle, like form submissions or flaky external APIs. Wrapping every parse in a try/catch just to avoid a crash usually means you wanted safeParse from the start.
Optional, nullable, and default
These three get conflated constantly, and they mean different things:
-
.optional(): the key may be missing entirely. Type becomesT | undefined. -
.nullable(): the value may benull. Type becomesT | null. -
.default(value): if the input isundefined, Zod substitutesvalue, so the output is never undefined.
const ProfileSchema = z.object({
bio: z.string().optional(), // string | undefined
avatar: z.string().nullable(), // string | null
theme: z.enum(["light", "dark"]).default("light"), // always a string out
});
That last one is subtle and useful. The input type allows theme to be absent, but the output type is a guaranteed "light" | "dark". Zod tracks input and output types separately, which is exactly why .default() and .transform() behave the way they do.
Coercion: the fix for stringly-typed everything
Environment variables and query params arrive as strings. You want numbers and booleans. z.coerce does the conversion before validating:
const EnvSchema = z.object({
PORT: z.coerce.number().default(3000),
DEBUG: z.coerce.boolean(),
});
const env = EnvSchema.parse(process.env);
// env.PORT is a real number, not the string "8080"
Be careful with z.coerce.boolean() though, since it uses JavaScript truthiness. The string "false" is a non-empty string, so it coerces to true. That trips up basically everyone once. For real boolean env vars I skip coercion and do it explicitly:
const flag = z
.enum(["true", "false"])
.transform((v) => v === "true");
Custom rules with refine and superRefine
Built-in checks won't cover business logic. .refine() adds a custom predicate to a single value or object:
const SignupSchema = z
.object({
password: z.string().min(8),
confirm: z.string(),
})
.refine((data) => data.password === data.confirm, {
message: "Passwords don't match",
path: ["confirm"], // attaches the error to the right field
});
When you need multiple conditional checks or want to add several issues at once, reach for .superRefine(), which hands you a context object to push issues onto:
const OrderSchema = z
.object({ quantity: z.number(), inStock: z.number() })
.superRefine((order, ctx) => {
if (order.quantity > order.inStock) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Only ${order.inStock} left in stock`,
path: ["quantity"],
});
}
});
Transforms and nested schemas
.transform() reshapes data after it validates. The output type reflects the transformed shape, so downstream code sees the new form:
const DateSchema = z.string().transform((s) => new Date(s));
type ParsedDate = z.infer<typeof DateSchema>; // Date, not string
Schemas compose. Nest objects, wrap them in arrays, mix and match, and the inferred type follows along for free:
const PostSchema = z.object({
title: z.string(),
tags: z.array(z.string()),
author: z.object({
name: z.string(),
email: z.string().email(),
}),
comments: z.array(
z.object({
body: z.string(),
createdAt: z.coerce.date(),
})
),
});
type Post = z.infer<typeof PostSchema>;
This is where writing the schema once really pays off. That Post type, with its nested author and array of comments with coerced dates, would be tedious and error-prone to hand-write and keep in sync.
A realistic fetch wrapper
Here's the pattern I actually use at API boundaries. It validates the response and gives you a typed result without a bare as cast anywhere:
async function fetchUser(id: number): Promise<User> {
const res = await fetch(`/api/users/${id}`);
const json = await res.json(); // typed as any, the danger zone
return UserSchema.parse(json); // validated, now genuinely a User
}
If the backend changes a field or sends garbage, this throws at the boundary with a clear message, instead of a confusing undefined is not a function three layers deep in a component.
Pitfalls I've actually hit
-
Casting instead of parsing.
data as Usercompiles and does nothing at runtime. If you wrote a schema, use it, don't cast. -
z.coerce.boolean()on env strings."false"is truthy, so it becomestrue. Use an enum-plus-transform for real flags. -
Forgetting
.email(),.min(),.url().z.string()alone accepts""and"not an email". The refinements are where the real validation lives. -
Confusing
.optional()with.nullable(). Missing key versus explicitnullare different failures; picking the wrong one lets bad data through. - Validating deep inside components. Validate once at the boundary, then trust the typed value everywhere downstream. Re-parsing the same object in five places is wasted work and noise.
-
Ignoring the input/output type split. After a
.transform()or.default(),z.infergives the output type. If you need the input shape, that'sz.input<typeof schema>.
FAQ
Is Zod a replacement for TypeScript?
No, they work together. TypeScript checks your code at compile time; Zod checks data at runtime. You keep both. Zod's advantage is that z.infer lets one schema feed your static types too, so they never drift.
Does Zod slow down my app?
For typical payloads the cost is negligible; you're doing a handful of type checks. It matters if you validate huge arrays in a hot loop, but for API responses and form input you won't notice it. Validate at the boundary once, not repeatedly.
parse or safeParse: which should I default to?
Use safeParse when invalid data is expected and you want to handle it (forms, external APIs). Use parse when a failure is a genuine bug you'd rather crash on (app config, environment validation at startup).
How do I get a plain TypeScript type out of a schema?
type User = z.infer<typeof UserSchema>. That gives the validated output type. For the pre-transform input type, use z.input<typeof UserSchema>.
Wrapping up
The mental model is small: TypeScript guards your code, Zod guards your data, and z.infer keeps them in sync so you write the shape exactly once. Start where it hurts most: put a schema on your least trustworthy API response or on process.env at boot, and use safeParse so failures are handled, not thrown. Once you've watched it catch one malformed payload before it reached the UI, adding schemas at the rest of your trust boundaries stops feeling like a chore and starts feeling like the obvious default.
If you want to go deeper on the type machinery behind z.infer, our guide to TypeScript generics explained with real examples covers the generics that make it tick, and TypeScript utility types every developer should know pairs well with the shapes Zod produces.