Cross-Field Validation with Zod and React Hook Form
9 min read

Cross-Field Validation with Zod and React Hook Form

Form validation rarely stands alone on a single field. Almost every production form has rules involving more than one field at once — a password confirmation must match the password, an end date must be greater than a start date, or a company name is only required if the user selects a business account type. These patterns are called cross-field validation, and this is one of the validation parts most often implemented incorrectly. This article thoroughly discusses how to handle cross-field validation using Zod and React Hook Form, from simple cases to scalable schema architecture for complex forms.

What Is Cross-Field Validation?

Cross-field validation is a validation rule that can’t be determined only from one field’s value — it needs values from other fields to decide whether an input is valid or not.

Regular validation:
  password → minimum 8 characters  (only needs the password value)

Cross-field validation:
  confirmPassword → must match password  (needs two field values)
  endDate → must be greater than startDate    (needs two field values)
  companyName → required if isCompany = true  (value depends on another field's state)

Zod provides three mechanisms to handle this, each suitable for different situations:

flowchart TD
    A{How many conditions<br/>need checking?} -- One condition --> B{Need multiple<br/>errors at once?}
    B -- No --> C[".refine()"]
    B -- Yes --> D[".superRefine()"]
    A -- Many conditions --> E{Form structure<br/>differs per value?}
    E -- Yes --> F["discriminatedUnion()"]
    E -- No --> D

.refine() — Validating One Condition

Use .refine() when there’s only one cross-field rule to check and it produces only one error message.

Password Confirmation

This is the most common case: the confirmPassword field must be identical to password.

// ANTI-PATTERN: validation done in the component, the schema knows nothing
const onSubmit = (data) => {
  if (data.password !== data.confirmPassword) {
    setError("confirmPassword", { message: "Passwords don't match" });
    return;
  }
  // submit process...
};

// CORRECT: validation lives in the schema, the component stays clean
import { z } from "zod";

const loginSchema = z
  .object({
    password: z.string().min(8, "Minimum 8 characters"),
    confirmPassword: z.string(),
  })
  .refine((data) => data.password === data.confirmPassword, {
    message: "Password confirmation doesn't match",
    path: ["confirmPassword"],
  });

The path parameter is the key here. Without it, the error appears at the root form level and React Hook Form doesn’t know which field to mark red. With path: ["confirmPassword"], the error is directed exactly to the intended field.

Date Range Validation

The same pattern applies to date ranges — the error is directed to the “second” field considered wrong:

const bookingSchema = z
  .object({
    startDate: z.date({ required_error: "Start date is required" }),
    endDate: z.date({ required_error: "End date is required" }),
  })
  .refine((data) => data.endDate > data.startDate, {
    message: "End date must be after the start date",
    path: ["endDate"],
  });
.refine() only produces one error per validation. If you need to display several errors at once from one cross-field validation, .refine() isn’t enough — use .superRefine() instead.

.superRefine() — Full Control

Use .superRefine() when there are many conditions to check, or when the validation needs to produce several errors on different fields simultaneously.

Conditional Required Fields

Scenario: companyName is only required if isCompany is true.

// ANTI-PATTERN: companyName always required in the schema
const schema = z.object({
  isCompany: z.boolean(),
  companyName: z.string().min(1), // always fails when empty, even though it's not always needed
});

// CORRECT: companyName optional in the schema, conditional validation in superRefine
const registrationSchema = z
  .object({
    isCompany: z.boolean(),
    companyName: z.string().optional(),
    taxId: z.string().optional(),
  })
  .superRefine((data, ctx) => {
    if (data.isCompany) {
      if (!data.companyName) {
        ctx.addIssue({
          code: z.ZodIssueCode.custom,
          message: "Company name is required for business accounts",
          path: ["companyName"],
        });
      }

      if (!data.taxId) {
        ctx.addIssue({
          code: z.ZodIssueCode.custom,
          message: "Tax ID is required for business accounts",
          path: ["taxId"],
        });
      }
    }
  });

With .superRefine(), both errors — companyName and taxId — can appear together. Users don’t need to submit twice just to find all missing fields.

Complex Password Validation

.superRefine() is also useful when validating one field with many interrelated rules:

const passwordChangeSchema = z
  .object({
    currentPassword: z.string().min(1),
    newPassword: z.string().min(8),
    confirmPassword: z.string(),
  })
  .superRefine((data, ctx) => {
    // Make sure the new password differs from the old one
    if (data.newPassword === data.currentPassword) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: "New password must not be the same as the old password",
        path: ["newPassword"],
      });
    }

    // Make sure the confirmation matches
    if (data.newPassword !== data.confirmPassword) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: "Password confirmation doesn't match",
        path: ["confirmPassword"],
      });
    }
  });
sequenceDiagram
    participant User
    participant RHF as React Hook Form
    participant Zod as Zod Schema
    participant UI

    User->>RHF: submit form
    RHF->>Zod: validate all fields
    Zod->>Zod: run superRefine
    Zod-->>RHF: ZodIssue array (can be > 1)
    RHF->>RHF: distribute errors to each field
    RHF-->>UI: render errors on confirmPassword & newPassword
    UI-->>User: show all errors at once

discriminatedUnion() — Scalable Architecture

When one field determines the entire form structure, .refine() and .superRefine() start to feel like workarounds. The solution is z.discriminatedUnion() — a union type where one field acts as a discriminator determining which schema shape applies.

Case: Payment Methods

// ANTI-PATTERN: one big schema with all fields optional
const schema = z.object({
  paymentMethod: z.enum(["credit_card", "bank_transfer", "ewallet"]),
  cardNumber: z.string().optional(),     // only for credit_card
  bankAccount: z.string().optional(),    // only for bank_transfer
  ewalletPhone: z.string().optional(),   // only for ewallet
}).superRefine((data, ctx) => {
  // conditional logic grows as payment methods grow
  if (data.paymentMethod === "credit_card" && !data.cardNumber) {
    ctx.addIssue({ ... });
  }
  // ... and so on, the longer it gets the harder to maintain
});

// CORRECT: discriminatedUnion — each method has its own schema
const paymentSchema = z.discriminatedUnion("paymentMethod", [
  z.object({
    paymentMethod: z.literal("credit_card"),
    cardNumber: z.string().min(16, "Invalid card number").max(16),
    cardExpiry: z.string().regex(/^\d{2}\/\d{2}$/, "MM/YY format"),
    cardCvv: z.string().length(3, "CVV must be 3 digits"),
  }),
  z.object({
    paymentMethod: z.literal("bank_transfer"),
    bankCode: z.string().min(1, "Choose the destination bank"),
    bankAccount: z.string().min(10, "Invalid account number"),
    accountName: z.string().min(1, "Account holder name is required"),
  }),
  z.object({
    paymentMethod: z.literal("ewallet"),
    ewalletProvider: z.enum(["gopay", "ovo", "dana"]),
    ewalletPhone: z.string().regex(/^08\d{8,11}$/, "Invalid phone number format"),
  }),
]);

Every time paymentMethod changes, Zod automatically knows which schema to use for validation. No optional() workarounds, no manual conditional logic growing longer as options increase.

flowchart TD
    A[paymentMethod] --> B{Discriminator value}
    B -- "credit_card" --> C["Schema: cardNumber<br/>cardExpiry, cardCvv"]
    B -- "bank_transfer" --> D["Schema: bankCode<br/>bankAccount, accountName"]
    B -- "ewallet" --> E["Schema: ewalletProvider<br/>ewalletPhone"]
    C --> F[Validation runs<br/>per the active schema]
    D --> F
    E --> F

Integration with React Hook Form

Zod connects to React Hook Form through zodResolver from the @hookform/resolvers package:

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";

const schema = z
  .object({
    password: z.string().min(8),
    confirmPassword: z.string(),
  })
  .refine((data) => data.password === data.confirmPassword, {
    message: "Password confirmation doesn't match",
    path: ["confirmPassword"],
  });

type FormValues = z.infer<typeof schema>;

function ChangePasswordForm() {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<FormValues>({
    resolver: zodResolver(schema),
    mode: "onChange", // validation runs on every value change
  });

  const onSubmit = (data: FormValues) => {
    console.log(data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("password")} type="password" />
      {errors.password && <p>{errors.password.message}</p>}

      <input {...register("confirmPassword")} type="password" />
      {errors.confirmPassword && <p>{errors.confirmPassword.message}</p>}

      <button type="submit">Save</button>
    </form>
  );
}

Dynamic Fields and useWatch

For forms where the displayed fields change based on another field’s value, use useWatch so the component reacts to changes:

import { useForm, useWatch } from "react-hook-form";

function RegistrationForm() {
  const { register, control, handleSubmit } = useForm({
    resolver: zodResolver(registrationSchema),
  });

  // the component re-renders only when isCompany changes
  const isCompany = useWatch({ control, name: "isCompany" });

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("isCompany")} type="checkbox" />

      {isCompany && (
        <>
          <input {...register("companyName")} placeholder="Company Name" />
          <input {...register("taxId")} placeholder="Tax ID" />
        </>
      )}

      <button type="submit">Register</button>
    </form>
  );
}
Fields not rendered are still validated by Zod. This means if companyName is displayed conditionally in the UI but the schema doesn’t use .optional(), validation will still fail even though the field isn’t visible. Always make conditional fields .optional() in the schema, then set their requirement via .superRefine() or discriminatedUnion.

Approach Comparison

NeedApproachReason
One simple cross-field rule.refine()Concise, enough for one condition
Many conditions, possibly multiple errors.superRefine()Full control over each issue
Form structure differs per enum valuediscriminatedUnion()Separate schema per case, no workarounds
Very dynamic form + many optionsdiscriminatedUnion() + useWatchScalable and type-safe

Anti-Patterns to Avoid

// ✗ Cross-field error without a path — appears at the root, not on the field
const schema = z.object({ ... }).refine(
  (data) => data.endDate > data.startDate,
  { message: "Invalid date" } // no path!
);
// ✓ Always include a path so the error appears on the right field
const schema = z.object({ ... }).refine(
  (data) => data.endDate > data.startDate,
  { message: "End date must be after the start date", path: ["endDate"] }
);

// ✗ Cross-field validation done in the component handler
const onSubmit = (data) => {
  if (data.password !== data.confirmPassword) {
    setError("confirmPassword", ...); // logic scattered everywhere
  }
};
// ✓ All validation lives in the Zod schema
const schema = z.object({ ... }).refine(...);

// ✗ Conditional fields not using .optional()
const schema = z.object({
  isCompany: z.boolean(),
  companyName: z.string().min(1), // always fails when isCompany = false
});
// ✓ optional in the schema, its requirement set in superRefine
const schema = z.object({
  isCompany: z.boolean(),
  companyName: z.string().optional(),
}).superRefine((data, ctx) => {
  if (data.isCompany && !data.companyName) {
    ctx.addIssue({ ... });
  }
});

// ✗ Using superRefine for everything including cases that could use discriminatedUnion
// ✓ Use discriminatedUnion when one field determines the entire form shape

Cross-Field Validation Implementation Checklist

SCHEMA:
  □ Every .refine() has a clear path parameter
  □ Conditional fields use .optional() in the main schema
  □ Needs of > 1 error at once use .superRefine()
  □ Enum/choice-based forms use discriminatedUnion

RHF INTEGRATION:
  □ zodResolver used as the resolver in useForm
  □ "onChange" or "onBlur" mode adjusted to the desired UX
  □ useWatch used for conditionally displayed fields
  □ errors from formState displayed on each related field

ARCHITECTURE:
  □ Validation lives in the schema, not in components or handlers
  □ Form types derived from z.infer<typeof schema>
  □ No manual setError except for server-side errors

Summary

  • .refine() for one simple cross-field rule — concise and easy to read, but only produces one error.
  • .superRefine() for complex validation with many conditions — use ctx.addIssue() to add errors to any fields simultaneously.
  • path is mandatory in every .refine() and ctx.addIssue() so React Hook Form knows which field to mark as having an error.
  • Conditional fields must be .optional() in the main schema — set their requirement programmatically in .superRefine().
  • discriminatedUnion() is the best choice when one field determines the entire form shape — cleaner, type-safe, and easy to extend.
  • Don’t validate cross-field in components — logic scattered across form handlers is hard to maintain and hard to test.
  • useWatch makes a component reactive to a specific field’s value without causing the entire form to re-render.

Portfolio