Zod 13 🛡️ Refinements — .refine() for custom validation logic
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
| Method | Signature | Emits |
|---|---|---|
.refine(fn, opts?) | fn: (val) => boolean | One issue on false |
.superRefine(fn) | fn: (val, ctx) => void | Any number of issues |
Options Object
| Key | Purpose |
|---|---|
message / error | Failure text |
path | Field path for the issue |
Behavioral Change (4.3.0+)
| Scenario | 4.2.1 | 4.3.0+ |
|---|---|---|
| Key absent, field optional, field refine | Runs | Does not run |
Key present as undefined | Runs | Runs |
| Object-level refine | Runs | Runs |
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
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Refinement not running on absent key | Zod 4.3.0+ behavior | Move check to object schema |
| Error on wrong field | No path in options | Set path: ["field"] |
| Multiple issues needed | .refine() emits one | Use .superRefine() |
| Async refinement ignored | Sync parse method | Use .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
| Item | Value |
|---|---|
| Method | .refine(fn, options?) |
| Function return | Boolean |
| Error code | custom |
| Path control | options.path |
| Multiple issues | Use .superRefine() |
| 4.3.0 change | Field refine skipped on absent optional key |
| Presence check location | Object schema |
Key takeaways:
.refine()adds a custom check without changing the type. It runs after the base schema passes, returns a boolean, and produces acustomissue on failure. The inferred type remains the base schema’s type .pathattributes the error to a field. In a cross-field refinement on an object schema,path: ["confirm"]makes the error appear on theconfirmfield rather than the object root..superRefine()is for multiple issues. It receives a context object withaddIssue, 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 dataordata.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.
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!