Zod superRefine for Cross-Field Rules with Correct Issue Paths

A Zod object schema validates each field against its own type, but it cannot express a rule that compares two fields — like “confirm must equal password” or “address is required only when shipping is true” — until you reach for .refine() or .superRefine() and, critically, attach each issue to the right path so the error lands on the field the user must fix.

When to Use This Recipe

Use .superRefine() (or its simpler sibling .refine()) whenever a validity rule depends on more than one field at once. Per-field checks belong in the field’s own schema; cross-field checks run after the shape is known to be type-correct. This is the schema-driven counterpart to the imperative approach in Cross-Field Validation Strategies, and it slots directly into the Schema-Based Validation with Zod workflow already used across the site.

Decide between the two refinements like this:

  • .refine() — one rule, one message, one target field. Cleanest for a single comparison such as a password match.
  • .superRefine() — multiple interdependent rules, conditional requireds, or when you need to emit several issues at different paths in one pass. It hands you a ctx so you can call ctx.addIssue() as many times as needed.

If a rule touches only one field (length, format, range), keep it on that field with .min(), .regex(), etc., as shown in Using Zod for Complex Form Schemas — refinements are strictly for the cross-field case.

superRefine issue routing by path An object that passed field-level parsing enters superRefine. The block adds a password-mismatch issue with path confirm and a conditional-required issue with path city. Each issue lands on its named field via the path array. path routes each issue to its field parsed object fields type-valid .superRefine(ctx) ctx.addIssue({ path }) runs per rule path: ['confirm'] passwords mismatch path: ['city'] required when shipping
Inside superRefine you emit one issue per broken rule, and its path array determines which field the message attaches to.

Minimal Working Implementation

Define field-level constraints normally, then chain .superRefine() on the object to add the cross-field rules. Each ctx.addIssue call must include a path so the resulting issue maps to a specific field rather than the form root. This example covers both classic cases: a password-confirm match and a conditional required.

import { z } from 'zod';

const signupSchema = z
  .object({
    password: z.string().min(8, 'At least 8 characters.'),
    confirm: z.string(),
    shipToAddress: z.boolean(),
    city: z.string().optional(),
  })
  .superRefine((data, ctx) => {
    // Rule 1: confirmation must match — attach to the confirm field.
    if (data.confirm !== data.password) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: 'Passwords do not match.',
        path: ['confirm'], // lands on #confirm, not the form root
      });
    }

    // Rule 2: city is required only when shipping is requested.
    if (data.shipToAddress && !data.city?.trim()) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: 'City is required for shipping.',
        path: ['city'],
      });
    }
  });

type Signup = z.infer<typeof signupSchema>;

Map the parse result onto the DOM. safeParse keeps validation synchronous and lets you fold each issue’s path into a field-keyed dictionary — the same shape consumed by the site’s <form novalidate> + setCustomValidity house style:

function applyIssues(form: HTMLFormElement, result: z.SafeParseReturnType<unknown, Signup>) {
  // Clear previous verdicts first.
  form.querySelectorAll<HTMLInputElement>('[name]').forEach((el) => el.setCustomValidity(''));

  if (result.success) return true;

  for (const issue of result.error.issues) {
    const name = issue.path[0]?.toString();
    const field = name ? form.elements.namedItem(name) : null;
    if (field instanceof HTMLInputElement) {
      // Only set the first message per field so reportValidity shows one error.
      if (!field.validationMessage) field.setCustomValidity(issue.message);
    }
  }
  form.reportValidity();
  return false;
}

Parameter & Option Reference

Parameter / Option Type Purpose
.refine(check, opts) (val) => boolean + { message, path } Single cross-field rule; return false to fail and set path to target a field.
.superRefine((data, ctx) => …) callback receiving ctx Multiple rules in one pass; call ctx.addIssue per failure.
ctx.addIssue({ … }) issue object Emits an issue; requires code, message, and usually path.
path (string | number)[] Field route for the issue; ['confirm'] targets a field, ['items', 0, 'qty'] targets a nested/array field.
code z.ZodIssueCode Use z.ZodIssueCode.custom for refinement-driven errors.
ctx.addIssue({ fatal: true }) then return z.NEVER boolean Stops further refinement when a prerequisite is missing.

Verification Steps

A Vitest assertion that paths route correctly:

import { expect, test } from 'vitest';

test('superRefine routes issues to the right fields', () => {
  const r = signupSchema.safeParse({
    password: 'longenough', confirm: 'different',
    shipToAddress: true, city: '',
  });
  expect(r.success).toBe(false);
  if (!r.success) {
    const paths = r.error.issues.map((i) => i.path.join('.'));
    expect(paths).toContain('confirm');
    expect(paths).toContain('city');
  }
});

Edge Cases & Failure Modes

Forgetting path, so the error lands on the root. An addIssue call without path produces an issue with an empty path array, which most form bindings treat as a form-level error with no field to focus. The user sees a message but no highlighted input. Always supply path: ['fieldName'], and for nested data use the full route such as ['items', 0, 'quantity'].

Refinements never run while a field-level check is failing — within that field. .superRefine on the object always runs even if some fields are type-invalid, but a refinement chained on an individual field is skipped if that field’s prior checks already failed. If your cross-field rule reads a field that might be the wrong type, guard it (if (typeof data.x !== 'string') return;) or place the rule on the object so it runs after the shape is known.

Order and short-circuiting with z.NEVER. When one rule is a precondition for another (you cannot compare dates until both parse), call ctx.addIssue({ ..., fatal: true }) and return z.NEVER to stop the rest of the superRefine body from reading undefined values. Without it, a later rule may throw or emit a confusing second error for the same root cause.

Factoring a Refinement into a Reusable Function

Inline superRefine bodies grow quickly once a form has several interdependent rules. Because the callback is an ordinary function, you can extract it and share one implementation across every schema that needs the same rule. Type the extracted function against a minimal structural shape — just the fields it reads — so it composes with any object schema that satisfies that shape, not only the one it was written for.

import { z } from 'zod';

// The rule only cares about these two fields, so type it structurally.
interface PasswordPair {
  password: string;
  confirm: string;
}

// A refinement is just (data, ctx) => void — extract and reuse it.
function passwordsMatch(data: PasswordPair, ctx: z.RefinementCtx): void {
  if (data.confirm !== data.password) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: 'Passwords do not match.',
      path: ['confirm'],
    });
  }
}

const registerSchema = z
  .object({
    email: z.string().email(),
    password: z.string().min(8),
    confirm: z.string(),
  })
  .superRefine(passwordsMatch);

const resetSchema = z
  .object({
    token: z.string(),
    password: z.string().min(8),
    confirm: z.string(),
  })
  .superRefine(passwordsMatch); // same rule, different schema

The structural parameter type matters: if you had typed passwordsMatch against the full registerSchema inferred type, TypeScript would reject resetSchema because its shape differs. Keeping the contract narrow — only the fields the rule inspects — is what makes the helper portable. The same technique works for date-range checks ({ start: Date; end: Date }), quantity-versus-stock rules, and any comparison you expect to appear on more than one form.

Async Cross-Field Rules with safeParseAsync

Some cross-field rules cannot resolve synchronously — a coupon code whose validity depends on the selected plan, or a username that must be unique given the chosen tenant. Both .refine() and .superRefine() accept an async callback, but a schema containing one must be run with safeParseAsync (or parseAsync); calling the synchronous safeParse on it throws at runtime rather than returning a failed result.

const orderSchema = z
  .object({
    planId: z.string(),
    coupon: z.string().optional(),
  })
  .superRefine(async (data, ctx) => {
    if (!data.coupon) return; // nothing to check
    const ok = await couponAppliesToPlan(data.coupon, data.planId);
    if (!ok) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: 'This coupon is not valid for the selected plan.',
        path: ['coupon'],
      });
    }
  });

// Must await the async variant — safeParse would throw here.
const result = await orderSchema.safeParseAsync(formData);

Two practical guards apply. First, debounce network-backed refinements so you are not issuing a request on every keystroke; run the async schema on blur or submit and keep a cheap synchronous schema for live feedback. Second, remember that every non-fatal issue is still collected, so a slow uniqueness check and a fast local rule can both fail in the same pass — surface a pending state on the field until the promise settles rather than briefly showing it as valid.

Frequently Asked Questions

When should I pick refine over superRefine?

Use .refine() for a single rule with one message and one target field — it is terser. Reach for .superRefine() when you need to emit several issues, route them to different paths, or short-circuit dependent rules with fatal and z.NEVER. Anything refine does, superRefine can do with more control.

How do I target a nested or array field with path?

Pass the full route as an array mixing keys and indices, e.g. path: ['lineItems', 2, 'quantity']. When you map issues to the DOM, join that path (issue.path.join('.')) into your field name or selector so the message reaches the exact input it describes.

Does safeParse stop at the first cross-field error?

No. Every ctx.addIssue call inside a single superRefine pass is collected, so you get all cross-field failures at once in result.error.issues unless you explicitly halt with fatal: true and z.NEVER. That lets you highlight all problem fields in one render rather than forcing the user to fix them one at a time.

Why does safeParse throw when my schema has an async refinement?

The synchronous safeParse cannot await a promise, so Zod refuses to run a schema that contains an async refine or superRefine and throws instead of returning a result. Switch that call site to safeParseAsync (or parseAsync) and await it. If any refinement anywhere in the schema tree is asynchronous, the whole parse must use the async variant.

Can I share one refinement function across multiple schemas?

Yes. A refinement is just a (data, ctx) => void function, so extract it and pass it to .superRefine() on every schema that needs it. Type its data parameter against a minimal interface containing only the fields it reads — not the full inferred schema type — so any object schema with a compatible shape can accept it. This keeps a rule like a password match or a start-before-end date check defined in exactly one place.

← Back to Schema-Based Validation with Zod