Chapter 10.3☕ 20 min read

Zod & Runtime Validation

Zod is the X-ray machine for your data — one schema, two guarantees.

01The X-Ray Machine

TypeScript's type system is incredibly powerful, but it has one fundamental limitation: it operates at COMPILE TIME. Once your code compiles, all types are erased. The actual data flowing through your application at runtime might not match the types you declared — an API might change, a user might submit invalid input, or a third-party service might send unexpected data. This is where RUNTIME validation becomes essential, and this is where Zod shines.

Think of a hospital's X-ray machine at Osmania General Hospital. The machine serves TWO purposes. First, it helps the doctor DIAGNOSE the patient at the moment (runtime validation — checking the actual data right now). Second, the machine's design and capabilities define the TYPE of diagnosis possible (compile-time type — knowing what to expect based on the machine's specifications). A single machine provides instant visibility AND sets expectations.

Zod is your X-ray machine for data. You define a schema — a description of what the data should look like — ONCE. That schema does TWO things:

  • At compile time: TypeScript infers the type from your schema using z.infer. This gives you full type safety in your editor.
  • At runtime: Zod validates incoming data against the schema. If the data doesn't match, Zod throws a detailed error telling you exactly what's wrong.

This is called the Single Source of Truth pattern. Instead of writing an interface AND writing validation logic separately (which always drifts apart as your code evolves), you write the schema ONCE. The type and the validation are ALWAYS in sync. No duplication, no drift, no surprises.

02What is Zod?

Zod is a TypeScript-first schema validation library. Let's start with the basics.

Installation

npm install zod

Basic Schemas

import { z } from "zod";

// Primitive schemas
const nameSchema = z.string();
const ageSchema = z.number();
const isActiveSchema = z.boolean();

// Object schema
const UserSchema = z.object({
  name: z.string(),
  age: z.number(),
  email: z.string().email(),
  isActive: z.boolean().default(true),
});

// Parsing — throws on failure
const user = UserSchema.parse({
  name: "Imran",
  age: 25,
  email: "imran@example.com",
});
// user is typed as:
// { name: string; age: number; email: string; isActive: boolean; }

Safe Parsing (Recommended for most cases)

const result =
  UserSchema.safeParse(inputData);

if (result.success) {
  // result.data is typed!
  console.log(result.data.name);
} else {
  // result.error is ZodError
  console.error(result.error.errors);
}

Common Schema Types

z.string()             // String
z.number()             // Number
z.boolean()            // Boolean
z.null()               // Null
z.undefined()          // Undefined
z.nullable(z.string()) // string | null
z.optional(z.string()) // string | undefined
z.array(z.string())    // string[]
z.object({})           // Object
z.union([z.string(), z.number()]) // string | number
z.literal("admin")     // Literal "admin"
z.enum(["a", "b"])     // Enum
z.date()               // Date
z.any()                // any
z.unknown()            // unknown
03z.infer & Type Inference

The real power of Zod is the z.infer utility type, which extracts the TypeScript type from your schema. This eliminates duplication entirely.

// Step 1: Define the schema ONCE
const UserSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(2),
  email: z.string().email(),
  age: z.number().int().positive(),
  role: z.enum(["admin", "user"]),
  tags: z.array(z.string()).default([]),
  createdAt: z.date().default(() => new Date()),
});

// Step 2: Derive the type from schema
type User = z.infer<typeof UserSchema>;

// User is equivalent to:
// {
//   id: string;
//   name: string;
//   email: string;
//   age: number;
//   role: "admin" | "user";
//   tags: string[];
//   createdAt: Date;
// }

Nested Schemas

const AddressSchema = z.object({
  street: z.string(),
  city: z.string(),
  zip: z.string().length(6),
});

const OrderSchema = z.object({
  id: z.string().uuid(),
  user: UserSchema,
  items: z.array(
    z.object({
      productId: z.string(),
      quantity: z.number().int().min(1),
      price: z.number().positive(),
    })
  ),
  total: z.number().positive(),
  status: z.enum([
    "pending", "shipped", "delivered"
  ]),
});

type Order = z.infer<typeof OrderSchema>;

Schema Composition & Reuse

// Partial schema (all fields optional)
const PartialUser =
  UserSchema.partial();

// Pick specific fields
const UserName =
  UserSchema.pick({ name: true });

// Omit fields
const UserWithoutEmail =
  UserSchema.omit({ email: true });

// Extend a schema
const AdminSchema =
  UserSchema.extend({
    permissions: z.array(z.string()),
  });

// Merge schemas
const FullProfile =
  UserSchema.merge(ProfileSchema);

With z.infer, your TypeScript types and your runtime validation are ALWAYS in sync. Change the schema, and both the type and validation update automatically. No more remembering to update an interface when you change a validation rule!

04Zod Traps & Best Practices

Zod is powerful, but there are common patterns and pitfalls you should know.

Trap 1: Forgetting safeParse for External Data

Always use safeParse for data from external sources (APIs, user input, files). parse() is for data you trust. safeParse is for data you don't.

Trap 2: Creating Duplicate Types

Don't write both an interface AND a Zod schema. Use z.infer to derive the type from the schema. One source of truth only.

Trap 3: Not Refining After Parsing

// Use refinements for complex rules
const PasswordSchema = z.string()
  .min(8, "Too short")
  .refine(
    (val) => /[A-Z]/.test(val),
    "Must have uppercase"
  );

Trap 4: Ignoring Type Performance

Very large Zod schemas (100+ fields) can slow down TypeScript compilation. For massive schemas, split them into smaller, composable schemas.

05Zod Cheatsheet

Here's your complete cheatsheet for Zod!

Installation:

npm install zod

Basic Usage:

const Schema = z.object({ name: z.string() });
type T = z.infer<typeof Schema>; // compile-time type
Schema.parse(data);   // throws on failure
Schema.safeParse(data); // { success, data | error }

Common Schema Methods:

z.string()
z.string().email().min(2).max(100)
z.number().int().positive()
z.boolean()
z.array(z.string())
z.object({})
z.union([...])
z.enum(["a", "b"])
.z.nullable() .optional() .default(val)
.partial() .pick({}) .omit({}) .extend({})
.refine(fn, msg)

Key Rules:

  • Use z.infer to derive types — never duplicate type definitions
  • Use safeParse for external/untrusted data, parse for trusted data
  • Compose schemas using extend, merge, pick, omit for reuse
  • Use .refine() for custom validation logic beyond simple types
  • Use .default() to provide fallback values for missing fields

The Golden Rule: "Zod is the X-ray machine for your data — one schema provides TWO guarantees: compile-time types with z.infer and runtime validation with .parse(). Never duplicate. Never drift. Single source of truth, bhai!"

Key Takeaways

  • Zod provides runtime validation AND compile-time types from a single schema definition
  • Use z.infer to derive TypeScript types — eliminates duplication
  • safeParse returns a discriminated union: { success: true, data } | { success: false, error }
  • parse() throws ZodError on failure — use try/catch or prefer safeParse for external data
  • Compose schemas with .extend(), .pick(), .omit(), .merge() for reusability
  • Use .refine() for custom validation and .default() for fallback values
Course Search
Search across all chapters & stages
📖

Search the course

Type any topic — branching, stash, rebase, hooks — and jump straight to that chapter.

merge branchesgit stashundo commitrebase