TypeScript types vanish at runtime. The data crossing your API boundaries doesn’t. Zod is how you bridge: schemas that validate at runtime AND give you the TypeScript type. This post is the working set.
The basics
import { z } from "zod";
const User = z.object({
id: z.number().int().positive(),
email: z.string().email(),
name: z.string().min(1).max(120),
age: z.number().int().min(0).max(150).optional(),
});
type User = z.infer<typeof User>;
// ^ { id: number; email: string; name: string; age?: number }
One source of truth. The schema validates; the type is derived.
Parsing
const result = User.parse(input); // throws on invalid
const result = User.safeParse(input); // returns { success, data | error }
if (!parsed.success) {
return res.status(400).json({ errors: parsed.error.format() });
}
safeParse for API boundaries (you control error response). parse for trusted internal calls.
Transforms
const TrimmedString = z.string().trim();
const LowercaseEmail = z.string().email().toLowerCase();
const StringifiedNumber = z.string().transform((s) => parseInt(s, 10));
const Query = z.object({
page: z.coerce.number().int().min(1).default(1),
q: z.string().trim().min(1),
});
coerce for query strings (everything’s a string). transform for normalization. The output type reflects the transformed shape.
Branded types
const UserId = z.number().int().brand<"UserId">();
const PostId = z.number().int().brand<"PostId">();
type UserId = z.infer<typeof UserId>;
function getUser(id: UserId) { /* ... */ }
getUser(123 as any); // type error: not branded
getUser(UserId.parse(123)); // ok
Compile-time guard against passing the wrong ID type. Cheap; high payoff in domain-heavy code.
Discriminated unions
const Event = z.discriminatedUnion("kind", [
z.object({ kind: z.literal("click"), x: z.number(), y: z.number() }),
z.object({ kind: z.literal("scroll"), delta: z.number() }),
z.object({ kind: z.literal("submit"), formId: z.string() }),
]);
type Event = z.infer<typeof Event>;
TypeScript narrows on the discriminator. Cleaner than z.union for tagged variants.
Refinements
const Password = z.string()
.min(8)
.refine((s) => /[A-Z]/.test(s), "must contain uppercase")
.refine((s) => /[0-9]/.test(s), "must contain digit");
const SignupForm = z.object({
password: Password,
passwordConfirm: z.string(),
}).refine(
(data) => data.password === data.passwordConfirm,
{ message: "passwords don't match", path: ["passwordConfirm"] }
);
Cross-field validation: .refine on the object level with a path for proper error attribution.
API boundary pattern
// Request schema
const CreatePostBody = z.object({
title: z.string().min(1).max(200),
body: z.string().min(1),
tags: z.array(z.string()).max(10).default([]),
});
// Response schema (for OpenAPI / contract testing)
const PostResponse = z.object({
id: z.string().uuid(),
title: z.string(),
createdAt: z.string().datetime(),
});
app.post("/posts", async (req, res) => {
const parsed = CreatePostBody.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ errors: parsed.error.flatten() });
}
const post = await createPost(parsed.data);
return res.json(PostResponse.parse(post));
});
Validate in; validate out (in dev / staging at least). Catches drift early.
Error formatting
parsed.error.flatten();
// { formErrors: [], fieldErrors: { email: ["Invalid email"] } }
parsed.error.format();
// Tree-shaped, includes nested object errors
parsed.error.issues;
// Raw issues array
For UI: format() matches form structure. For APIs: flatten() is concise.
Forms with React Hook Form
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
const Schema = z.object({ email: z.string().email() });
type FormData = z.infer<typeof Schema>;
const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(Schema),
});
End-to-end: schema validates form, types the form, validates on submit. Same schema can validate the server too.
OpenAPI generation
import { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi";
extendZodWithOpenApi(z);
const User = z.object({
email: z.string().email().openapi({ example: "[email protected]" }),
}).openapi("User");
Generate OpenAPI specs from Zod schemas. Useful for Hono or any TS API.
Alternatives
| Strengths | |
|---|---|
| Zod | Default; ecosystem; mature |
| Valibot | Tiny bundle (10× smaller); functional |
| ArkType | TS-first syntax; ergonomic |
| TypeBox | JSON Schema underneath; great for OpenAPI |
| io-ts | Functional; verbose |
For most: Zod. Pick Valibot for client-side bundle sensitivity.
Performance
Zod is fast enough for nearly all apps. If validation shows up in profiles: consider safeParseAsync only when needed, avoid huge nested unions, prefer z.literal over z.refine for enum cases.
For 100k-RPS hot paths: switch to Valibot or hand-roll.
Common mistakes
1. Re-using schemas across boundaries when they should differ
User from DB has fields passwordHash, verifiedAt. User to API client should not. Make distinct schemas; map between them.
2. Optional vs nullable confusion
z.string().optional() // string | undefined
z.string().nullable() // string | null
z.string().nullish() // string | null | undefined
Match exactly what the data shape is.
3. Coercion in places it shouldn’t be
z.coerce.number() on a JSON body coerces strings to numbers silently. For strict validation, use z.number() and let validation fail.
4. Re-creating schemas in hot paths
Schema construction has overhead. Define schemas at module scope, not inside request handlers.
5. Trusting parsed types past async boundaries
Once parsed, the data is correct. But JSON.stringify / DB roundtrips can re-introduce drift. Re-validate on the way back if you must.
Read this next
If you want my Zod schema patterns for APIs + OpenAPI, it’s at rajpoot.dev .
Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .