</>
DevToolHub
Lines of code on a dark monitor with syntax highlighting

Zod Schema Tutorial: Validate TypeScript Data the Right Way

·9 min read
Quick answer: Zod is a TypeScript-first schema validation library that lets you define a schema once and get both runtime validation and static type inference from it. No duplicate type definitions. Install with npm i zod, define schemas with z.object(), and call .parse() or .safeParse() to validate data.

I wasted months writing TypeScript interfaces for my API responses, then writing separate validation logic to check if the data actually matched. Two sources of truth, constantly drifting apart. Then I found Zod, and the whole problem disappeared.

Zod lets you define a schema — a single source of truth — that handles both validation and type inference. You write z.string() once, and TypeScript knows the parsed result is a string. No casting, no as unknown as User, no hoping for the best.

Why Zod Exists (And Why You Should Care)

TypeScript types vanish at runtime. That's the fundamental problem. You can declare interface User { name: string; age: number } all day, but when your API returns { name: null, age: "twenty-five" }, TypeScript won't save you. Your app crashes at 2 AM, and the error message says Cannot read property 'toLowerCase' of null somewhere deep in your rendering logic.

Zod fills this gap. It validates data at runtime and infers TypeScript types at compile time — from the same schema definition. One schema, two guarantees.

Here's what that looks like in practice:

import { z } from "zod";

const UserSchema = z.object({
  name: z.string().min(1),
  age: z.number().int().positive(),
  email: z.string().email(),
});

// Type is inferred automatically — no separate interface needed
type User = z.infer<typeof UserSchema>;

// Runtime validation
const result = UserSchema.safeParse(apiResponse);
if (result.success) {
  console.log(result.data.name); // TypeScript knows this is string
} else {
  console.error(result.error.issues); // Detailed error messages
}

The z.infer line is where the magic happens. TypeScript extracts the type from your schema, so User is always { name: string; age: number; email: string } — and it updates automatically when you change the schema.

Zod vs Yup vs Joi: Which Validation Library Wins?

Before diving deeper, here's how Zod stacks up against the two other major players. I've used all three in production.

FeatureZodYupJoi
TypeScript inferenceNative — z.infer gives exact typesPartial — InferType exists but less preciseNone — requires manual types
Bundle size (min+gzip)~13 KB~13 KB~35 KB (not tree-shakeable)
RuntimeBrowser + NodeBrowser + NodeNode-first (browser needs polyfills)
Error messagesStructured ZodError with path, code, messageString-based, less structuredDetailed but verbose
EcosystemtRPC, React Hook Form, Astro, Next.jsFormik (built-in), React Hook FormHapi (built-in), Express
Composability.merge(), .extend(), .pick(), .omit().concat(), limited composition.append(), .keys()
Async validation.refine() with async functionsBuilt-in async supportBuilt-in async support
Learning curveLow — API reads like plain EnglishLow — similar to ZodMedium — more verbose API
Schema transforms.transform(), .pipe(), .preprocess().transform().custom()
First release202020142012
My take: if you're writing TypeScript, Zod wins outright. The type inference alone is worth it. Yup is fine if you're already deep in a Formik codebase. Joi belongs on the server, and even there I'd pick Zod for new projects.

Basic Schemas: Primitives and Objects

Every Zod schema starts with a primitive or a combinator. Here are the building blocks.

Primitives map directly to JavaScript/TypeScript types:

z.string()    // string
z.number()    // number
z.boolean()   // boolean
z.date()      // Date
z.bigint()    // bigint
z.undefined() // undefined
z.null()      // null

Objects are where you'll spend most of your time:

const ProductSchema = z.object({
  id: z.number(),
  name: z.string(),
  price: z.number().positive(),
  tags: z.array(z.string()),
  metadata: z.record(z.string(), z.unknown()), // { [key: string]: unknown }
});

Optional and nullable — these are different things, and mixing them up is a common source of bugs:

z.string().optional()  // string | undefined — field can be missing
z.string().nullable()  // string | null — field exists but can be null
z.string().nullish()   // string | null | undefined — both

In API responses, null and undefined mean different things. A missing field (undefined) usually means "not provided." A null field means "explicitly empty." Zod forces you to handle both correctly.

Adding Constraints: Making Schemas Strict

Raw type checks are just the start. Real validation needs constraints. Zod chains them fluently:

const SignupSchema = z.object({
  username: z.string()
    .min(3, "Username must be at least 3 characters")
    .max(20, "Username can't exceed 20 characters")
    .regex(/^[a-z0-9_]+$/, "Only lowercase letters, numbers, and underscores"),

  email: z.string()
    .email("Invalid email format"),

  password: z.string()
    .min(8, "Password must be at least 8 characters"),

  age: z.number()
    .int("Age must be a whole number")
    .min(13, "Must be at least 13 years old")
    .max(120, "Invalid age"),
});

Every constraint accepts a custom error message as the last argument. Skip them and you get Zod's defaults, which are decent ("String must contain at least 3 character(s)") but not user-friendly for form validation.

The .email() validator checks RFC 5322 format. It catches user@ and @domain.com but won't catch typos like user@gmial.com. For real email validation, send a confirmation email — no regex in any library can replace that.

Advanced Patterns: Unions, Discriminated Unions, and Transforms

This is where Zod gets powerful.

Unions handle "one of these types":

const ResponseSchema = z.union([
  z.object({ status: z.literal("success"), data: z.array(z.string()) }),
  z.object({ status: z.literal("error"), message: z.string() }),
]);

Discriminated unions are the same concept but faster — Zod checks the discriminator field first and skips irrelevant branches:

const EventSchema = z.discriminatedUnion("type", [
  z.object({ type: z.literal("click"), x: z.number(), y: z.number() }),
  z.object({ type: z.literal("scroll"), offset: z.number() }),
  z.object({ type: z.literal("keypress"), key: z.string() }),
]);

Use discriminated unions when your objects share a common "type" or "kind" field. They give better error messages and perform roughly 3-5x faster than regular unions on objects with many variants.

Transforms convert data during validation:

const DateStringSchema = z.string()
  .transform((val) => new Date(val))
  .pipe(z.date()); // Ensures the Date is valid

const CommaSeparatedSchema = z.string()
  .transform((val) => val.split(",").map((s) => s.trim()))
  .pipe(z.array(z.string().min(1)));

The .pipe() method chains a second schema after the transform. Input is string, transform converts to Date, then z.date() validates the result. If someone passes "not-a-date", the pipe catches the invalid Date object.

Custom Validation with .refine() and .superRefine()

Built-in validators cover 90% of cases. For the other 10%, there's .refine():

const PasswordSchema = z.object({
  password: z.string().min(8),
  confirmPassword: z.string(),
}).refine(
  (data) => data.password === data.confirmPassword,
  {
    message: "Passwords don't match",
    path: ["confirmPassword"], // Error appears on this field
  }
);

.superRefine() gives you access to the full error context, letting you add multiple errors at once:

const TeamSchema = z.object({
  members: z.array(z.string()),
}).superRefine((data, ctx) => {
  if (data.members.length < 2) {
    ctx.addIssue({
      code: z.ZodIssueCode.too_small,
      minimum: 2,
      type: "array",
      inclusive: true,
      message: "Team needs at least 2 members",
    });
  }
  const unique = new Set(data.members);
  if (unique.size !== data.members.length) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: "Duplicate members found",
    });
  }
});

Rule of thumb: use .refine() for single cross-field checks. Use .superRefine() when you need multiple conditional errors or complex logic.

Schema Composition: Reuse Without Repetition

Real applications share fields across schemas. Zod handles this with .extend(), .merge(), .pick(), and .omit():

const BaseUserSchema = z.object({
  name: z.string(),
  email: z.string().email(),
});

// Add fields
const FullUserSchema = BaseUserSchema.extend({
  id: z.number(),
  createdAt: z.date(),
});

// Pick specific fields
const LoginSchema = BaseUserSchema.pick({ email: true });

// Remove fields
const PublicUserSchema = FullUserSchema.omit({ email: true });

// Merge two schemas
const UserWithSettingsSchema = FullUserSchema.merge(
  z.object({ theme: z.enum(["light", "dark"]), language: z.string() })
);

.partial() makes all fields optional — useful for PATCH endpoints where any field can be updated:

const UpdateUserSchema = BaseUserSchema.partial();
// { name?: string; email?: string }

This mirrors TypeScript's Partial<T> utility type, except it also adjusts the runtime validation. That's the Zod advantage — type and validation stay in sync.

Error Handling: Parse vs SafeParse

Zod gives you two parsing strategies:

// .parse() — throws ZodError on failure
try {
  const user = UserSchema.parse(unknownData);
  // user is fully typed here
} catch (error) {
  if (error instanceof z.ZodError) {
    console.log(error.issues);
    // [{ code: "invalid_type", expected: "string", received: "number", path: ["name"], message: "..." }]
  }
}

// .safeParse() — never throws, returns a result object
const result = UserSchema.safeParse(unknownData);
if (result.success) {
  console.log(result.data); // typed User
} else {
  console.log(result.error.flatten());
  // { formErrors: [], fieldErrors: { name: ["Expected string, received number"] } }
}

Use .safeParse() for form validation and API endpoints where you want to return structured errors. Use .parse() in pipelines where invalid data should halt execution — middleware, data migrations, CLI tools.

The .flatten() method on ZodError is a lifesaver for form UIs. It groups errors by field name, so you can map them directly to input fields without manual path parsing.

Real-World Pattern: Validating API Responses

Here's a complete pattern I use in every project. Want to convert raw JSON to Zod schemas automatically? Try the JSON to Zod converter — paste your API response and get a schema instantly.

import { z } from "zod";

const ApiResponseSchema = z.object({
  data: z.object({
    users: z.array(z.object({
      id: z.number(),
      name: z.string(),
      role: z.enum(["admin", "editor", "viewer"]),
    })),
    total: z.number(),
    page: z.number(),
  }),
  meta: z.object({
    requestId: z.string().uuid(),
    timestamp: z.string().datetime(),
  }),
});

type ApiResponse = z.infer<typeof ApiResponseSchema>;

async function fetchUsers(page: number): Promise<ApiResponse["data"]> {
  const res = await fetch(`/api/users?page=${page}`);
  const json = await res.json();
  const parsed = ApiResponseSchema.parse(json);
  return parsed.data;
}

If the API returns unexpected data, Zod throws immediately at the boundary — not three components deep where the symptom makes no sense. This pattern has saved me hours of debugging on every project where I've used it.

For generating TypeScript types from existing JSON data without writing schemas by hand, check the JSON to TypeScript converter.

FAQ

Does Zod work with React Hook Form?

Yes, and it's the recommended approach. Install @hookform/resolvers and pass your Zod schema to useForm({ resolver: zodResolver(YourSchema) }). The form types are inferred from the schema, and validation runs on both change and submit events. This replaces manual register validation rules entirely.

How does Zod handle recursive types (like a tree)?

Use z.lazy() to define recursive schemas. For example: const CategorySchema: z.ZodType<Category> = z.object({ name: z.string(), children: z.lazy(() => z.array(CategorySchema)) }). You need the explicit type annotation (z.ZodType<Category>) because TypeScript can't infer recursive types without a hint. Depth isn't limited, but deeply nested data will slow down parsing proportionally.

Can Zod strip unknown fields from objects?

By default, Zod passes through unknown fields (like TypeScript's structural typing). Call .strict() to reject unknown fields, or .strip() to silently remove them. For API endpoints, .strip() is usually what you want — it prevents clients from injecting unexpected fields while keeping existing ones valid. Use .passthrough() if you need to preserve unknown fields.

Is Zod fast enough for production?

Zod parses roughly 50,000-100,000 simple objects per second on a modern machine. For API validation (parsing one request at a time), it's irrelevant — the network round-trip dwarfs parsing time by 1000x. For batch processing millions of rows, consider @anatine/zod-pbf or pre-compiling schemas with zod-to-json-schema + ajv. In 3 years of using Zod in production, parsing speed has never been my bottleneck.

Next Steps