---
title: Add Path to Refinement Errors
impact: MEDIUM
impactDescription: Errors without path show at object level; adding path highlights the specific field that failed
tags: refine, path, errors, forms
---

## Add Path to Refinement Errors

When using `.refine()` on object schemas for cross-field validation, add a `path` option to indicate which field the error relates to. Without it, the error appears at the object level, making form error display confusing.

**Incorrect (error at object level):**

```typescript
import { z } from "zod";

const formSchema = z
  .object({
    password: z.string().min(8),
    confirmPassword: z.string(),
  })
  .refine(
    data => data.password === data.confirmPassword,
    { message: "Passwords do not match" } // No path specified
  );

const result = formSchema.safeParse({
  password: "secret123",
  confirmPassword: "different",
});

if (!result.success) {
  const flattened = result.error.flatten();
  // {
  //   formErrors: ['Passwords do not match'],  // At form level!
  //   fieldErrors: {}  // Empty - no field association
  // }
}

// Form UI can't highlight which field has the error
```

**Correct (error with path):**

```typescript
import { z } from "zod";

const formSchema = z
  .object({
    password: z.string().min(8),
    confirmPassword: z.string(),
  })
  .refine(data => data.password === data.confirmPassword, {
    message: "Passwords do not match",
    path: ["confirmPassword"], // Error appears on this field
  });

const result = formSchema.safeParse({
  password: "secret123",
  confirmPassword: "different",
});

if (!result.success) {
  const flattened = result.error.flatten();
  // {
  //   formErrors: [],
  //   fieldErrors: {
  //     confirmPassword: ['Passwords do not match']  // Associated with field
  //   }
  // }
}

// Form can now show error next to confirmPassword input
```

**Multiple cross-field validations:**

```typescript
const dateRangeSchema = z
  .object({
    startDate: z.coerce.date(),
    endDate: z.coerce.date(),
    minDays: z.number().optional(),
    maxDays: z.number().optional(),
  })
  .refine(data => data.endDate >= data.startDate, {
    message: "End date must be after start date",
    path: ["endDate"],
  })
  .refine(
    data => {
      if (!data.minDays) return true;
      const days = (data.endDate.getTime() - data.startDate.getTime()) / 86400000;
      return days >= data.minDays;
    },
    { message: "Date range is too short", path: ["endDate"] }
  )
  .refine(
    data => {
      if (!data.maxDays) return true;
      const days = (data.endDate.getTime() - data.startDate.getTime()) / 86400000;
      return days <= data.maxDays;
    },
    { message: "Date range is too long", path: ["endDate"] }
  );
```

**With superRefine for multiple path errors:**

```typescript
const orderSchema = z
  .object({
    billingAddress: z.object({
      street: z.string(),
      city: z.string(),
    }),
    shippingAddress: z.object({
      street: z.string(),
      city: z.string(),
    }),
    sameAsBilling: z.boolean(),
  })
  .superRefine((data, ctx) => {
    if (data.sameAsBilling) {
      // If sameAsBilling but addresses differ, show errors on shipping
      if (data.shippingAddress.street !== data.billingAddress.street) {
        ctx.addIssue({
          code: z.ZodIssueCode.custom,
          message: "Must match billing address",
          path: ["shippingAddress", "street"], // Nested path
        });
      }
      if (data.shippingAddress.city !== data.billingAddress.city) {
        ctx.addIssue({
          code: z.ZodIssueCode.custom,
          message: "Must match billing address",
          path: ["shippingAddress", "city"],
        });
      }
    }
  });
```

**When NOT to use this pattern:**

- When the error genuinely applies to the whole object
- Simple single-field refinements (path is implicit)

Reference: [Zod API - refine](https://zod.dev/api#refine)
