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

Merge and intersection

Combining independent schemas

The previous lesson built new schemas by picking and omitting fields from a single base. But sometimes you have two completely independent schemas that need to be combined. For example, you want to add the same pagination fields to every list response, or add audit timestamps to every entity.

.merge(): combine two object schemas

.merge() takes all the fields from two schemas and puts them together:

import { z } from "zod/v4";

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

const ContactSchema = z.object({
  name: z.string().min(1),
  email: z.string().email(),
});

const FullContactSchema = ContactSchema.merge(AddressSchema);
// Has: name, email, street, city, zip

Both schemas’ fields end up in the result. If both schemas define the same field, the second schema’s definition wins.

The pagination pattern

Here is where merge becomes really practical. Every list endpoint in an API returns the same pagination metadata: current page, items per page, total count, total pages. You do not want to repeat those fields in every response schema.

Define pagination once, then merge it wherever you need it:

const PaginationSchema = z.object({
  page: z.number().int().min(1),
  limit: z.number().int().min(1).max(100),
  total: z.number().int().nonnegative(),
  totalPages: z.number().int().nonnegative(),
});

// For books
const BookListResponse = z
  .object({
    items: z.array(BookSchema),
  })
  .merge(PaginationSchema);
// Has: items, page, limit, total, totalPages

// For users
const UserListResponse = z
  .object({
    items: z.array(UserSchema),
  })
  .merge(PaginationSchema);
// Has: items, page, limit, total, totalPages

PaginationSchema is defined once. Every list response merges it. If you later decide to add a hasNextPage field, you add it to PaginationSchema and every list response gets it automatically. No hunting through a dozen files to add the same field.

z.intersection(): type-level combination

There is also z.intersection(), which combines schemas at the type level. The data must satisfy both schemas:

const WithTimestamps = z.object({
  createdAt: z.string().datetime(),
  updatedAt: z.string().datetime(),
});

const BookWithTimestamps = z.intersection(BookSchema, WithTimestamps);

The difference from .merge(): .merge() creates a brand new object schema with all fields combined. .intersection() requires the data to independently pass both schemas. In practice, .merge() is more common for combining object schemas. Use .intersection() when you are combining non-object schemas or when you need strict intersection semantics.

Adding audit fields to any entity

Another common pattern: every entity in your database has createdAt, updatedAt, createdBy, and updatedBy fields. Define them once:

const AuditFields = z.object({
  createdAt: z.string().datetime(),
  updatedAt: z.string().datetime(),
  createdBy: z.string().uuid(),
  updatedBy: z.string().uuid(),
});

const AuditedBookSchema = BookSchema.omit({ createdAt: true }).merge(AuditFields);
// BookSchema already had createdAt; omit it first, then merge AuditFields

Notice the .omit() before .merge(). If BookSchema already has a createdAt field, you need to remove it first so there is no conflict when merging AuditFields.

[!NOTE] This pattern appears throughout the course series. The Authentication course’s user schema, the REST API Design course’s resource schemas, and the Database Design course’s table schemas all follow the “base + audit” pattern. Zod makes it composable.

With pick, omit, extend, merge, and intersection, you can build any schema from existing ones without duplication. The next lesson puts this all together into a reusable schema library.

Exercises

Exercise 1: Create a PaginationSchema. Merge it with a BookListSchema and a UserListSchema. Verify both have pagination fields.

Exercise 2: Add audit fields (createdAt, updatedAt) to three different entity schemas using merge.

Exercise 3: Compare .merge() and .intersection() by combining the same two schemas with each. Parse the same data and compare results.

Why define PaginationSchema separately and merge it, instead of adding pagination fields to each list schema?

← Pick, omit, and extend Reusable schemas →

© 2026 hectoday. All rights reserved.