| | |

Zod 13 🛡️ Refinements — .refine() for custom validation logic

⏱️ Estimated read: 10 min | |

Zod’s built-in validators cover types, ranges, formats, and lengths. They do not cover domain rules: “password and confirm password must match,” “the end date must be after the start date,” “this email must end with a specific domain.” .refine() is the method that attaches arbitrary validation logic to an existing schema without changing its inferred type . It runs after the base schema succeeds, receives the parsed value, and returns a boolean or a promise of one. A false result produces a validation issue at the current path; a custom message can be attached.

This chapter covers the .refine() method, its options object, the difference between .refine() and .superRefine(), and the behavioral change in Zod 4.3.0 that suppresses field-level refinements on absent optional keys. The sections that follow walk through the basic refinement, the options and path control, and the nested-object behavior that catches migrations by surprise.

Key point: .refine() adds a custom check to a schema without changing its inferred type; it runs after the base schema passes, returns a boolean, and can attach a message and a path—but in Zod 4.3.0+, a field-level refinement inside an object does not run when the optional key is absent.


a. The .refine() method and its options

.refine() takes a refinement function and an optional options object. The function receives the parsed value and returns true for valid or false for invalid. A string as the second argument is shorthand for the message; an object allows message (or error in v4) and path .

import { z } from "zod";

// Simple refinement: string must end with a specific domain
const EmailSchema = z.string().refine(
  (value) => value.endsWith("@frontendmasters.com"),
  { message: "Email must end with @frontendmasters.com" }
);

EmailSchema.parse("ana@frontendmasters.com"); // valid
EmailSchema.parse("ana@example.com");         // ZodError

The inferred type does not change. EmailSchema still infers as string; the refinement is a runtime check, not a type transformation. The error appears in issues with the code custom and the message supplied.

Refinements chain. A schema can have multiple .refine() calls, and each runs in sequence. The first failure does not stop the others by default; all failures are collected into the issues array.

const PasswordSchema = z
  .string()
  .min(8, "At least 8 characters")
  .refine((v) => /[A-Z]/.test(v), "Must contain an uppercase letter")
  .refine((v) => /[0-9]/.test(v), "Must contain a digit");

The options object’s path property controls where the error is reported. Without it, the error appears at the path of the schema that owns the refinement. With it, the error can be attributed to a specific field in an object schema.

const FormSchema = z.object({
  password: z.string(),
  confirm: z.string(),
}).refine((data) => data.password === data.confirm, {
  message: "Passwords do not match",
  path: ["confirm"], // error appears on the confirm field
});

b. .refine() versus .superRefine()

.superRefine() is the lower-level form. It receives the value and a context object, and the context’s addIssue method lets the refinement add multiple issues with different codes, messages, and paths . .refine() is a convenience wrapper that returns a boolean and produces a single issue when it returns false.

Use .refine() when the check is a simple predicate with one failure message. Use .superRefine() when the check needs to emit multiple issues, attach different codes, or control paths dynamically.

// superRefine: multiple issues from one check
const OrderSchema = z.object({
  items: z.array(z.object({ qty: z.number() })),
}).superRefine((order, ctx) => {
  order.items.forEach((item, index) => {
    if (item.qty <= 0) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: "Quantity must be positive",
        path: ["items", index, "qty"],
      });
    }
  });
});

The context object in v4 also exposes the current path and the input, which allows refinements to make decisions based on sibling fields. This is the mechanism for cross-field validation: the refinement is attached to the object schema, receives the whole object, and adds an issue at the path of the field that should display the error.


c. The Zod 4.3.0 behavioral change for optional fields

In Zod 4.2.1 and earlier, a .refine() or .superRefine() attached to an optional field inside an object ran even when the key was absent. In Zod 4.3.0, this changed. The handlePropertyResult function in the core schemas no longer surfaces field-level errors when the key is missing and the field is optional . The behavior was introduced alongside .exactOptional() and was confirmed by the maintainer as intentional: “optional() means an enclosing object may omit the key, and that optionality bubbles through field-level superRefine; presence-sensitive validation belongs on the object schema” .

// Zod 4.2.1: refinement runs on absent key
// Zod 4.3.0+: refinement does NOT run on absent key
const schema = z.object({
  field: z.any().optional().superRefine((val, ctx) => {
    ctx.addIssue({ code: "custom", message: "This should fail" });
  }),
});

schema.parse({}); // 4.2.1: throws; 4.3.0+: does not throw
schema.parse({ field: undefined }); // throws in both

The migration implication is direct. A schema that relied on field-level refinement to validate “the key must be present” must move that check to the object schema, where the refinement receives the whole object and can check key in data or data.key !== undefined. A field-level refinement should be used only for checks that apply when the value exists, not for checks that require the value to exist .

This change was not documented in the release notes at the time of the report, and a follow-up comment from a user noted that it broke production code . The behavior is stable and intended; the fix is to restructure presence-sensitive checks onto the object schema.


Complete Example Session

// ============================================
// PART 1: BASIC REFINEMENT
// ============================================
import { z } from "zod";

const EvenNumber = z.number().refine(
  (n) => n % 2 === 0,
  { message: "Must be even" }
);

EvenNumber.parse(4);  // valid
EvenNumber.parse(5);  // ZodError: Must be even

// ============================================
// PART 2: CROSS-FIELD REFINEMENT
// ============================================
const DateRange = z.object({
  start: z.date(),
  end: z.date(),
}).refine((data) => data.end > data.start, {
  message: "End must be after start",
  path: ["end"],
});

// ============================================
// PART 3: SUPERREFINE FOR MULTIPLE ISSUES
// ============================================
const Form = z.object({
  password: z.string(),
  confirm: z.string(),
}).superRefine((data, ctx) => {
  if (data.password.length < 8) {
    ctx.addIssue({
      code: "custom",
      message: "Password must be at least 8 characters",
      path: ["password"],
    });
  }
  if (data.password !== data.confirm) {
    ctx.addIssue({
      code: "custom",
      message: "Passwords do not match",
      path: ["confirm"],
    });
  }
});

// ============================================
// PART 4: THE OPTIONAL FIELD CHANGE (4.3.0+)
// ============================================
// Field-level refinement does NOT run on absent key
const OptionalWithRefine = z.object({
  value: z.string().optional().refine((v) => v !== "bad", {
    message: "Cannot be 'bad'",
  }),
});

OptionalWithRefine.parse({}); // passes in 4.3.0+
OptionalWithRefine.parse({ value: "bad" }); // fails

// Presence-sensitive check belongs on the object
const PresenceSensitive = z.object({
  value: z.string().optional(),
}).refine((data) => data.value !== undefined, {
  message: "value is required",
  path: ["value"],
});

The parts covered the basic predicate, the cross-field check with path control, the multi-issue superRefine, and the optional-field behavior change with the object-level workaround.


Quick Reference

Refinement Methods

MethodSignatureEmits
.refine(fn, opts?)fn: (val) => booleanOne issue on false
.superRefine(fn)fn: (val, ctx) => voidAny number of issues

Options Object

KeyPurpose
message / errorFailure text
pathField path for the issue

Behavioral Change (4.3.0+)

Scenario4.2.14.3.0+
Key absent, field optional, field refineRunsDoes not run
Key present as undefinedRunsRuns
Object-level refineRunsRuns

Best Practices

✅ Do This:

// Use refine for simple predicates
z.string().refine((v) => v.includes("@"), "Must contain @")  // ✅

// Use path to attribute cross-field errors
.refine((d) => d.a === d.b, { path: ["b"], message: "..." })  // ✅

// Put presence checks on the object schema
z.object({ x: z.string().optional() })
  .refine((d) => d.x !== undefined, { path: ["x"] })         // ✅

❌ Don’t Do This:

// Field-level presence check (4.3.0+)
z.object({ x: z.string().optional().refine((v) => v !== undefined) }) // ❌

// Refinement function with side effects
.refine((v) => { log(v); return true; })                      // ❌

// Async refinement without async parse
.refine(async (v) => ...) // needs parseAsync                  // ❌

Common Pitfalls

PitfallWhy It HappensFix
Refinement not running on absent keyZod 4.3.0+ behaviorMove check to object schema
Error on wrong fieldNo path in optionsSet path: ["field"]
Multiple issues needed.refine() emits oneUse .superRefine()
Async refinement ignoredSync parse methodUse .parseAsync()

Real-World Examples

1. Domain suffix check

z.string().refine((v) => v.endsWith("@company.com"));

2. Password match

z.object({ a: z.string(), b: z.string() })
  .refine((d) => d.a === d.b, { path: ["b"] });

3. Date range

z.object({ start: z.date(), end: z.date() })
  .refine((d) => d.end > d.start, { path: ["end"] });

4. Multiple issues

.superRefine((d, ctx) => { if (...) ctx.addIssue({ ... }); });

5. Even number

z.number().refine((n) => n % 2 === 0);

6. Object-level presence check

z.object({ x: z.string().optional() })
  .refine((d) => "x" in d, { path: ["x"] });

7. Custom error message

.refine(fn, { message: "Invalid value" });

8. Async refinement

await schema.parseAsync(data); // for async refine

9. Refinement after transform

z.string().transform((s) => s.length).refine((n) => n > 3);

10. Path in nested object

path: ["user", "email"]

Visual

Refinement Pipeline

┌─────────────────────────────────────────────────────────┐
│  z.string().min(1).refine(fn)                           │
│                                                         │
│  input ──► base schema ──► refinement ──► output        │
│              │                  │                       │
│              ▼                  ▼                       │
│           type check        custom check                │
│           length check      boolean result              │
│                                                         │
│  Refinement runs only after base schema passes          │
└─────────────────────────────────────────────────────────┘

Refine vs SuperRefine

┌─────────────────────────────────────────────────────────┐
│  .refine(fn)                  .superRefine(fn)          │
│                                                         │
│  Returns boolean              Receives ctx              │
│  One issue                    Any number of issues      │
│  Simple predicate             Complex logic            │
└─────────────────────────────────────────────────────────┘

The 4.3.0 Change

┌─────────────────────────────────────────────────────────┐
│  z.object({                                             │
│    x: z.string().optional().refine(fn)                  │
│  })                                                     │
│                                                         │
│  parse({})                                              │
│    │                                                    │
│    ▼                                                    │
│  4.2.1: refine runs (fn receives undefined)             │
│  4.3.0+: refine skipped (key absent)                    │
│                                                         │
│  Presence check belongs on the object schema            │
└─────────────────────────────────────────────────────────┘

Cross-Field Refinement

┌─────────────────────────────────────────────────────────┐
│  z.object({ password, confirm })                        │
│    .refine((data) => data.password === data.confirm, {  │
│      path: ["confirm"],                                 │
│      message: "Passwords do not match"                  │
│    })                                                   │
│                                                         │
│  The refinement receives the whole object               │
│  The path attributes the error to "confirm"             │
└─────────────────────────────────────────────────────────┘

Summary

ItemValue
Method.refine(fn, options?)
Function returnBoolean
Error codecustom
Path controloptions.path
Multiple issuesUse .superRefine()
4.3.0 changeField refine skipped on absent optional key
Presence check locationObject schema

Key takeaways:

  • .refine() adds a custom check without changing the type. It runs after the base schema passes, returns a boolean, and produces a custom issue on failure. The inferred type remains the base schema’s type .
  • path attributes the error to a field. In a cross-field refinement on an object schema, path: ["confirm"] makes the error appear on the confirm field rather than the object root.
  • .superRefine() is for multiple issues. It receives a context object with addIssue, allowing one check to emit several issues with different paths and codes .
  • Zod 4.3.0 stopped running field-level refinements on absent optional keys. The maintainer confirmed this is intentional: optionality bubbles through field-level refinement, and presence-sensitive checks belong on the object schema .
  • The migration fix is structural. A refinement that checked “the key must exist” on an optional field must move to the parent object, where it can test "key" in data or data.key !== undefined.

Remember: .refine() is the escape hatch for domain rules that Zod’s built-in validators cannot express. It runs after the type is confirmed, so the refinement function can trust the value’s type. Use it for predicates, use .superRefine() for multi-issue checks, and use path to put the error where the user expects to see it. When a refinement on an optional field stops running, check the Zod version: 4.3.0 changed the behavior, and the fix is to move presence checks to the object level, not to fight the change.

Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!