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
ZodDefault; ecosystem; mature
ValibotTiny bundle (10× smaller); functional
ArkTypeTS-first syntax; ergonomic
TypeBoxJSON Schema underneath; great for OpenAPI
io-tsFunctional; 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 .