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

Refinements

When built-in validators are not enough

Zod’s built-in methods cover the common cases: minimum length, maximum value, email format, regex patterns. But what about “the password must contain at least one number”? Or “the end date must be after the start date”? Or “at least one of phone or email must be provided”?

These are custom validation rules that depend on your specific business logic. Zod handles them with refinements.

.refine(): one custom check

.refine() takes a function that returns true (valid) or false (invalid):

import { z } from "zod/v4";

const PasswordSchema = z
  .string()
  .min(8)
  .refine((val) => /[0-9]/.test(val), {
    message: "Password must contain at least one number",
  })
  .refine((val) => /[A-Z]/.test(val), {
    message: "Password must contain at least one uppercase letter",
  });

PasswordSchema.parse("MyPassword1"); // passes
PasswordSchema.parse("mypassword"); // fails: "Password must contain at least one number"
PasswordSchema.parse("mypassword1"); // fails: "Password must contain at least one uppercase letter"

Let’s walk through this. First, z.string().min(8) validates that the input is a string of at least 8 characters. Then the first .refine() checks if the string contains a digit. If not, it fails with the custom message. Then the second .refine() checks for an uppercase letter.

Each .refine() adds one custom check. The message property is the error shown when the check fails. Multiple refinements chain together, and all of them must pass.

Refinements on objects

Refinements really shine when you need to validate relationships between fields. Individual field validators cannot do this because they only see their own value. An object-level refinement sees the whole object:

const DateRangeSchema = z
  .object({
    startDate: z.string().datetime(),
    endDate: z.string().datetime(),
  })
  .refine((data) => data.endDate > data.startDate, {
    message: "End date must be after start date",
    path: ["endDate"], // attach the error to the endDate field
  });

DateRangeSchema.parse({
  startDate: "2024-06-01T00:00:00Z",
  endDate: "2024-01-01T00:00:00Z",
});
// fails: "End date must be after start date" (on endDate field)

The path option is important. Without it, the error is attached to the root object, and the client does not know which field to highlight. With path: ["endDate"], the error points directly to the end date field. This is exactly what a form UI needs to display the error in the right place.

The “at least one of” pattern

Here is a pattern you will encounter in production APIs:

const ContactInfoSchema = z
  .object({
    email: z.string().email().optional(),
    phone: z.string().min(7).optional(),
  })
  .refine((data) => data.email || data.phone, {
    message: "At least one of email or phone must be provided",
  });

ContactInfoSchema.parse({ email: "[email protected]" }); // passes
ContactInfoSchema.parse({ phone: "555-1234" }); // passes
ContactInfoSchema.parse({ email: "[email protected]", phone: "555-1234" }); // passes
ContactInfoSchema.parse({}); // fails: "At least one..."

Both fields are optional individually, but the refinement ensures at least one is present. Neither field is required on its own, but the object as a whole must have at least one contact method. This kind of cross-field logic is impossible with field-level validators alone.

.superRefine(): reporting multiple errors at once

There is a subtle problem with chaining multiple .refine() calls. They stop at the first failure. If a password is missing both a number and an uppercase letter, the user only sees the first error, fixes it, submits again, and then sees the second error. That is frustrating.

.superRefine() lets you report all errors in one pass:

const PasswordSchema = z
  .string()
  .min(8)
  .superRefine((val, ctx) => {
    if (!/[0-9]/.test(val)) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: "Must contain a number",
      });
    }
    if (!/[A-Z]/.test(val)) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: "Must contain an uppercase letter",
      });
    }
    if (!/[!@#$%]/.test(val)) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: "Must contain a special character",
      });
    }
  });

PasswordSchema.safeParse("abc");
// Three errors: too short, no number, no uppercase, no special character

Instead of returning true or false, you call ctx.addIssue() for each problem you find. All checks run regardless of earlier failures, so the user sees every issue at once and can fix them all in one go.

Refinement vs transform

A quick note on when to use which, since they can look similar:

Refinements validate. They return true or false (or add issues). The data comes out unchanged.

Transforms change the data. They return a new value. The data comes out different from how it went in.

// Refinement: check that it contains a number
z.string().refine((val) => /[0-9]/.test(val));

// Transform: convert to uppercase
z.string().transform((val) => val.toUpperCase());

If you need to check something, use a refinement. If you need to change something, use a transform. Sometimes you need both: validate with a refinement, then transform the data.

Up next: preprocessing and coercion, which solve the problem of data that arrives as the wrong type (like query parameters that are always strings).

Exercises

Exercise 1: Create a password schema with refinements: min 8 characters, at least one number, at least one uppercase letter.

Exercise 2: Create a date range schema where endDate must be after startDate. Use path to attach the error to endDate.

Exercise 3: Use .superRefine() to check multiple password requirements at once. Parse a weak password and verify all errors are reported.

Why use .superRefine() instead of multiple .refine() calls?

← Transforms Preprocessing and coercion →

© 2026 hectoday. All rights reserved.