Cross-field validation requires schema-level coordination rather than isolated field checks. When architecting Validation Logic & Schema Integration, developers must account for state dependencies that trigger conditional rules. This guide details the exact superRefine workflow for synchronizing dependent inputs without breaking type safety or causing render loops.

Define the Base Schema with Strict Typing

Establish a foundational object schema that declares all dependent fields. Use z.object() to enforce baseline types before applying conditional logic. This prevents runtime coercion errors and aligns with Integrating Zod for Schema Validation best practices.

Follow this sequence:

  • Declare base types for all interdependent fields using explicit Zod primitives.
  • Apply .strict() to reject extraneous keys, or .passthrough() when bridging uncontrolled form libraries that inject extra fields.
  • Export the base schema for downstream refinement and component-level type consumption.

Implement Cross-Field Validation via superRefine

Use .superRefine() rather than .refine() when you need to attach errors to specific field paths. .refine() only attaches to the root or a single path; .superRefine() calls ctx.addIssue() with an explicit path for each violation, enabling precise inline UI feedback.

Execute the refinement chain:

  • Chain .superRefine() directly on the base object schema.
  • Extract dependent values from the parsed context, with explicit null/undefined guards.
  • Call ctx.addIssue() with code: z.ZodIssueCode.custom, message, and path.
  • Return z.NEVER only when you want to halt further refinements in the chain. For compound rules that should all be evaluated, omit the return value.
import { z } from 'zod';

export const DateRangeSchema = z.object({
  startDate: z.date(),
  endDate: z.date(),
}).superRefine((data, ctx) => {
  // Guard against invalid dates before comparison
  if (!data.startDate || !data.endDate) return;

  if (data.endDate <= data.startDate) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: 'End date must be strictly after start date',
      path: ['endDate'],
    });
  }
});

export const PasswordConfirmSchema = z.object({
  password: z.string().min(8, 'Password must be at least 8 characters'),
  confirmPassword: z.string(),
}).superRefine((data, ctx) => {
  if (data.confirmPassword !== data.password) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: 'Passwords do not match',
      path: ['confirmPassword'],
    });
  }
});

Map Validation Errors to UI State and ARIA Attributes

Translate Zod’s ZodError format into accessible form state. Map issue.path arrays to aria-invalid and aria-describedby attributes to maintain WCAG 2.1 AA compliance during dynamic validation cycles.

import { z } from 'zod';

export function mapZodErrorsToFormState(error: z.ZodError): Record<string, string> {
  const fieldErrors: Record<string, string> = {};

  error.issues.forEach(issue => {
    // Flatten nested paths (e.g., ['address', 'street'] → 'address.street')
    const fieldPath = issue.path.join('.');
    // First error per path wins — avoids overwriting with redundant messages
    if (!fieldErrors[fieldPath]) {
      fieldErrors[fieldPath] = issue.message;
    }
  });

  return fieldErrors;
}

Apply these to your input elements:

function applyFieldError(
  inputEl: HTMLInputElement,
  errorMessage: string | undefined,
  errorId: string
): void {
  if (errorMessage) {
    inputEl.setAttribute('aria-invalid', 'true');
    inputEl.setAttribute('aria-describedby', errorId);
  } else {
    inputEl.removeAttribute('aria-invalid');
    inputEl.removeAttribute('aria-describedby');
  }
}

Use aria-live="polite" on the error container so dynamic error messages are announced without interrupting user input.

Common Pitfalls

  • Using .refine() instead of .superRefine() for multi-field errors: .refine() defaults the error to the root path, preventing targeted per-field inline feedback.
  • Not clearing dependent field state on parent reset: Stale validation flags remain in the UI, causing confusing UX and QA failures. On reset, run safeParse against the cleared form data and update your error map.
  • Overusing .transform() inside validation chains: .transform() changes the output type, which breaks type inference for downstream error handling. Keep transforms separate from cross-field refinements.
  • Blocking submission without updating aria-invalid states: Always synchronize ARIA state with validation results on submit, not just on blur.

FAQ

How do I target a specific field in a superRefine callback? Use ctx.addIssue({ path: ['fieldName'], message: '...', code: z.ZodIssueCode.custom }). The path array dictates exactly which form control receives the error message in the flattened error map.

Does superRefine run synchronously or asynchronously? It runs synchronously within .safeParse() / .parse(). This ensures deterministic validation before state updates propagate to the UI, eliminating race conditions in client-side form state. For async refinements, pass an async function to .refine() or .superRefine() and call .safeParseAsync() (or .parseAsync()) on the schema.

How do I handle optional dependent fields? Apply .optional() to the base schema field and explicitly check for undefined before executing cross-field logic in the refinement step. Zod skips validation for missing optional keys, but your guard clauses must handle partial data structures gracefully.