hectoday
DocsCoursesChangelog GitHub
DocsCoursesChangelog GitHub

Access Required

Enter your access code to view courses.

Invalid code

← All courses Validation for beginners with Zod

What is Zod

  • The problem with untyped data
  • Your first schema
  • Project setup

Primitive types

  • Strings
  • Numbers
  • Booleans, dates, and literals

Objects and arrays

  • Objects
  • Nested objects
  • Arrays
  • Unions and discriminated unions

Transforms and refinements

  • Transforms
  • Refinements
  • Preprocessing and coercion

Composing schemas

  • Pick, omit, and extend
  • Merge and intersection
  • Reusable schemas

Zod in practice

  • Validating API requests
  • Error formatting
  • Inferring TypeScript types
  • Cheatsheet and capstone

Nested objects

Real-world data has depth

So far our schemas have been flat: a name, an email, a message, all at the top level. But real-world data almost always has structure. A book has an author (an object). An order has a shipping address (an object). A user has preferences (an object). You need to validate not just the top level, but everything inside it too.

Zod handles this by nesting z.object() inside z.object().

Your first nested schema

import { z } from "zod/v4";

const AddressSchema = z.object({
  street: z.string().min(1),
  city: z.string().min(1),
  state: z.string().length(2),
  zip: z.string().regex(/^\d{5}$/),
});

const OrderSchema = z.object({
  id: z.string().uuid(),
  total: z.number().positive(),
  shippingAddress: AddressSchema, // nested object
});

The shippingAddress field is not a primitive. It is an entire AddressSchema object. When you parse an order, Zod validates the top-level fields (id, total) and also dives into the nested address to validate street, city, state, and zip.

What happens when a nested field fails?

OrderSchema.parse({
  id: "550e8400-e29b-41d4-a716-446655440000",
  total: 29.99,
  shippingAddress: {
    street: "123 Main St",
    city: "Portland",
    state: "OR",
    zip: "1234", // invalid: must be 5 digits
  },
});
// Error path: ["shippingAddress", "zip"]
// Message: "Invalid"

Look at that error path: ["shippingAddress", "zip"]. Zod tells you exactly where the problem is. Not just “invalid input” with no context, but the specific field in the specific nested object. The client can point to the exact form field that needs fixing.

Defining nested schemas separately

Notice that we defined AddressSchema as its own variable, then referenced it inside OrderSchema. This is the recommended approach because it lets you reuse the schema:

// src/schemas/address.ts
export const AddressSchema = z.object({
  street: z.string().min(1),
  city: z.string().min(1),
  state: z.string().length(2),
  zip: z.string().regex(/^\d{5}$/),
});

// src/schemas/order.ts
import { AddressSchema } from "./address.js";

export const OrderSchema = z.object({
  total: z.number().positive(),
  shippingAddress: AddressSchema,
  billingAddress: AddressSchema.optional(), // reuse!
});

AddressSchema is defined once and used for both shippingAddress (required) and billingAddress (optional). If the address format changes (say you add a country field), you update one schema and both places get the change.

Multiple levels of nesting

You can nest as deep as you need:

const CompanySchema = z.object({
  name: z.string().min(1),
  address: AddressSchema,
  contact: z.object({
    name: z.string().min(1),
    email: z.string().email(),
    phone: z.string().optional(),
  }),
});

Notice that contact is defined inline, right inside CompanySchema. That is fine when a nested object is only used in one place. Use separate variables when a schema is reused. Inline when it is not. There is no strict rule here; just keep things readable.

Optional nested objects

Sometimes the entire nested object is optional, not just individual fields within it:

const ProfileSchema = z.object({
  name: z.string().min(1),
  address: AddressSchema.optional(), // entire nested object is optional
});

ProfileSchema.parse({ name: "Alice" }); // passes: address omitted entirely
ProfileSchema.parse({
  name: "Alice",
  address: { street: "123 Main", city: "Portland", state: "OR", zip: "97201" },
}); // passes: address provided and valid
ProfileSchema.parse({
  name: "Alice",
  address: { street: "123 Main" }, // missing city, state, zip
}); // fails: address provided but incomplete

This is an important detail. When an optional nested object is provided, it must be fully valid. You cannot send a half-filled address. Either send the complete address or leave it out entirely. This prevents the kind of partial data that causes bugs downstream when you try to format an address and half the fields are missing.

Next, we will tackle arrays, because APIs rarely deal with just one item at a time.

Exercises

Exercise 1: Create a BookSchema with a nested AuthorSchema (name, bio). Parse valid and invalid data. Check the error paths.

Exercise 2: Reuse the same nested schema in two parent schemas. Change the nested schema. Verify both parents reflect the change.

Exercise 3: Make a nested object optional. Parse with the object omitted, with a valid object, and with an invalid object.

What happens when a nested object field fails validation?

← Objects Arrays →

© 2026 hectoday. All rights reserved.