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.

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.

← Back to Schema-Based Validation with Zod