Zod from Basics to Advanced: A Complete Guide to All Validation Types
9 min read

Zod from Basics to Advanced: A Complete Guide to All Validation Types

Validation is the foundation of a healthy system. Without good validation, data entering the system can cause bugs, inconsistencies, even security holes. Zod is a TypeScript-first schema validation library that lets you define data structures while automatically getting type inference. This article discusses Zod step by step, from simple primitive types to advanced features like discriminated unions, transforms, and branding, complete with immediately usable code examples.

What Is Zod?

Zod is a library designed for three main things: defining data schemas, performing runtime validation, and generating TypeScript types automatically from defined schemas. Because the schema and types come from the same source, you don’t need to write TypeScript interfaces manually then keep them in sync with the validation logic — they’re always consistent.

The simplest example:

import { z } from "zod";

const schema = z.string();

schema.parse("hello"); // valid
schema.parse(123);     // throws an error

If the validated data doesn’t match the schema, Zod throws an error by default. In the next section you’ll see how to handle errors without exceptions using safeParse.


Installation

Zod is installed like a regular npm package:

npm install zod

No additional dependencies are required, and Zod can be used directly in Node.js projects or on the frontend (for example for form validation).


Primitive Types

Validation starts from the most basic data types. Every primitive type in Zod has additional (chained) methods to tighten validation rules.

String

z.string();

Additional validations commonly used:

z.string().min(3);
z.string().max(10);
z.string().length(5);
z.string().email();
z.string().url();
z.string().uuid();
z.string().regex(/^[A-Z]+$/);
z.string().startsWith("A");
z.string().endsWith("Z");

Number

z.number();

Additional number validations:

z.number().min(1);
z.number().max(100);
z.number().int();
z.number().positive();
z.number().negative();
z.number().nonnegative();
z.number().multipleOf(5);

Boolean

z.boolean();

Date

z.date();

Dates can also be given minimum and maximum bounds:

z.date().min(new Date("2024-01-01"));
z.date().max(new Date());

Object Schemas

Object schemas are the most commonly used form, because almost all API payloads are objects with several fields.

const userSchema = z.object({
  name: z.string(),
  age: z.number(),
});

One of Zod’s main advantages is the ability to generate TypeScript types directly from the schema using z.infer:

type User = z.infer<typeof userSchema>;

With this, the validation schema becomes a single source of truth — you don’t need to define type User separately and risk it being out of sync with the actual validation rules.


Optional, Nullable, and Default Values

Not all fields are required. Zod provides several modifiers for handling optional fields, fields that can be null, or fields with default values.

Optional

Fields may be absent (undefined):

z.string().optional();

Nullable

Fields may be null:

z.string().nullable();

Default Values

Fields are automatically filled with a default value if not provided:

z.string().default("anonymous");

These three modifiers are often combined as needed, for example fields that are optional and also have a default value when not filled.


Array Validation

To validate lists of data, use z.array() with the item schema inside:

z.array(z.string());

Arrays can also be given additional constraints about the number of elements:

z.array(z.string()).min(1);
z.array(z.string()).max(5);
z.array(z.number()).nonempty();

nonempty() ensures the array can’t be empty — useful for cases like an order items list that must contain at least one item.


Enums and Literals

For limited, well-defined values, Zod provides literal and enum.

Literals

A literal validates that the value must be exactly equal to one specific value:

z.literal("admin");

Enums

An enum validates that the value must be one of a defined list:

z.enum(["admin", "user", "guest"]);

This approach pairs well with the enum concept at the database level — the Zod schema becomes the first validation layer before the data is processed further by the application.


Unions and Discriminated Unions

Sometimes a field can accept more than one data type, or the object structure changes depending on a certain field’s value. For these cases, Zod provides union and discriminatedUnion.

Union

A union validates that the value must match one of several schemas:

z.union([z.string(), z.number()]);

Discriminated Union

A discriminated union is used when the object structure changes based on one “discriminator” field:

z.discriminatedUnion("type", [
  z.object({ type: z.literal("a"), value: z.string() }),
  z.object({ type: z.literal("b"), value: z.number() }),
]);

The following diagram illustrates how Zod decides which schema to use based on the type field’s value:

flowchart TD
    A[Data comes in] --> B{Value of the type field?}
    B -- "a" --> C[Validate as schema type a, value: string]
    B -- "b" --> D[Validate as schema type b, value: number]
    B -- other --> E[Error: no matching schema]

Compared to a regular union, discriminatedUnion gives clearer error messages and better validation performance, because Zod doesn’t need to try all possible schemas one by one.


Nested Objects

Object schemas can be structured in layers to represent more complex data structures:

const schema = z.object({
  user: z.object({
    name: z.string(),
    address: z.object({
      city: z.string(),
    }),
  }),
});

Each nested object level still gets the same validation and type inference as a regular object schema.


Records (Dynamic Key Objects)

If the object structure has dynamic keys — for example objects whose keys are IDs or codes not known in advance — use z.record():

z.record(z.string());

You can also specify the key and value types separately:

z.record(z.string(), z.number());

This pattern suits cases like a { [productId: string]: number } mapping representing stock quantity per product.


Refinements (Custom Validation)

Not all validation rules can be expressed with built-in methods. For custom validation logic, Zod provides refine() and superRefine().

refine()

refine() suits simple validation on a single field:

z.string().refine((val) => val.includes("@"), {
  message: "Must contain @",
});

superRefine()

superRefine() suits validation involving several fields at once, for example ensuring two fields match:

z.object({
  password: z.string(),
  confirmPassword: z.string(),
}).superRefine((data, ctx) => {
  if (data.password !== data.confirmPassword) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: "Passwords don't match",
      path: ["confirmPassword"],
    });
  }
});

superRefine gives access to ctx, so you can add errors to specific fields (path) with specific messages.


Transforms and Preprocess

Besides validating, Zod can also change data before or after validation.

Transform

transform() changes data after validation succeeds:

z.string().transform((val) => val.trim());

Another example, converting a string to a number after validating it as a string:

z.string().transform((val) => Number(val));

Preprocess

preprocess() modifies input before validation runs:

z.preprocess((val) => Number(val), z.number());

This pattern is very suitable for HTML form inputs, where all inputs are essentially strings even though semantically they should be numbers.


Intersections

To combine two schemas into one, use z.intersection():

const a = z.object({ name: z.string() });
const b = z.object({ age: z.number() });

const merged = z.intersection(a, b);

The merged result will validate an object that must satisfy both schemas at once — having a name field of type string and an age field of type number.


Partial, Pick, Omit, and Extend

Already-defined object schemas can be modified without rewriting from scratch, using several built-in methods.

Partial

Makes all fields optional:

userSchema.partial();

Pick

Takes only some fields:

userSchema.pick({ name: true });

Omit

Removes certain fields:

userSchema.omit({ age: true });

Extend

Adds new fields to an existing schema:

userSchema.extend({ role: z.string() });

These methods are very helpful for maintaining consistency between related schemas, for example a schema for creating a user (without id) and a schema for updating a user (all fields optional).


Strict vs Passthrough

By default, Zod ignores additional properties not defined in the schema. This behavior can be changed with strict() or passthrough().

Strict

Rejects additional properties — validation fails if there are unknown fields:

z.object({ name: z.string() }).strict();

Passthrough

Allows additional properties to remain in the validation result:

z.object({ name: z.string() }).passthrough();

Choose strict() when you want to make sure the payload truly matches the contract, for example on public API endpoints. Use passthrough() when additional fields are indeed expected, for example when the schema only validates part of a larger object.


Safe Parse

The parse() method throws an error when validation fails, which means you must wrap it in a try/catch. As an alternative, safeParse() always returns a result object without throwing an exception:

const result = schema.safeParse(data);

if (!result.success) {
  console.log(result.error);
}
// ANTI-PATTERN: parse() at the boundary layer without try/catch can crash the request
const data = schema.parse(input);

// CORRECT: safeParse() at the boundary layer, errors handled explicitly
const result = schema.safeParse(input);
if (!result.success) {
  return { status: 400, error: result.error.format() };
}
const data = result.data;

safeParse() is more suitable at boundary layers like API controllers or services, where validation failure is a normal scenario to be handled with an error response, not an unhandled exception.


Error Handling and Custom Messages

Every validation method can be given a custom error message as the second parameter:

z.string().min(3, { message: "Minimum 3 characters" });

This custom message will appear in result.error when validation fails, so it can be directly displayed to users without manually mapping error codes. Besides per-field custom messages, Zod also supports a global error map for consistently adjusting error message format across the entire application.


Async Validation

Some validations require asynchronous operations, for example checking whether a username is already registered in the database. For this case, refine() can accept an async function:

z.string().refine(async (val) => {
  const exists = await checkUser(val);
  return !exists;
}, {
  message: "User already exists",
});
Schemas containing async refinements cannot be validated with regular parse() or safeParse(). Use parseAsync() or safeParseAsync(), or the validation will fail unexpectedly.

Branding and Nominal Typing

By default, TypeScript uses structural typing — two types with the same structure are considered equivalent even if semantically different (for example UserId and ProductId, both strings). Zod provides brand() to create nominal typing, where those types can’t be accidentally swapped:

const UserId = z.string().brand("UserId");

This feature is useful in large systems with many different IDs that are structurally all strings, but domain-wise must not be interchangeable.


When Are Built-in Validations Enough, When Is Refine or a Separate Schema Needed?

Not all validation cases need advanced features. The following diagram helps determine the appropriate approach:

flowchart TD
    A{Does the validation involve only one field?} -- Yes --> B[Use built-in methods: min, max, regex, etc.]
    A -- No --> C{Involves several fields at once?}
    C -- Yes --> D[Use superRefine]
    C -- No --> E{Structure changes based on one field?}
    E -- Yes --> F[Use discriminatedUnion]
    E -- No --> G[Use refine or transform as needed]

Zod Usage Best Practices

DO:
  ✓ separate domain schemas from form schemas
  ✓ use discriminatedUnion for complex structures
  ✓ leverage z.infer so types are always in sync with the schema
  ✓ use safeParse at boundary layers (API, services)

AVOID:
  ✗ validation logic in the UI separate from the schema
  ✗ parse() directly at the boundary layer without error handling
  ✗ schema duplication for structures that are actually the same

Separating domain schemas (the actual data representation) from form schemas (the raw input representation from users) helps keep validation clean, especially when forms use preprocess to convert strings to numbers or booleans before validating as a domain schema.


Summary

  • Zod defines schemas, performs runtime validation, and generates TypeScript types automatically via z.infer.
  • Primitive types (string, number, boolean, date) have chained methods to tighten rules, like .min(), .max(), .email().
  • optional(), nullable(), and default() handle fields that aren’t required or have built-in values.
  • discriminatedUnion is more appropriate than a regular union when the object structure changes based on one field.
  • refine() for custom single-field validation, superRefine() for validation involving several fields.
  • transform() changes data after validation, preprocess() changes input before validation — suitable for HTML forms.
  • partial(), pick(), omit(), and extend() help derive new schemas from existing ones without duplication.
  • Use safeParse() (not parse()) at boundary layers like APIs or services so validation errors can be handled explicitly.
  • Async validation must use parseAsync() or safeParseAsync().

Portfolio