zod
zod copied to clipboard
All errors—including those from refinements—to show up at once
On an empty form, Zod v4 will only run refinement checks (such as your "dob or age" condition) after the base schema validation passes, meaning all required fields must be valid before conditional refinements are checked and their errors are shown1. There is no documented way in Zod v4 to force refinements to run before or alongside base validations; this behavior is by design1.
Expected: want to throw error for dob or age along with all the errors
Use case: When submitting empty form or almost empty form all validations show up except refinements ones (want the user to aware of all error at once rather than try to submitting the form again and getting to know error still persist)
const formSchema = useMemo(
() =>
z
.object({
name: z.string().nonempty(t("name_is_required")),
phone_number: validators().phoneNumber.required,
same_phone_number: z.boolean(),
emergency_phone_number: validators().phoneNumber.required,
gender: z.enum(GENDERS, { required_error: t("gender_is_required") }),
blood_group: z.enum(BLOOD_GROUPS, {
required_error: t("blood_group_is_required"),
}),
age_or_dob: z.enum(["dob", "age"]),
date_of_birth: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, t("date_of_birth_format"))
.refine((date) => {
const parsedDate = dayjs(date);
return parsedDate.isValid() && !parsedDate.isAfter(dayjs());
}, t("enter_valid_dob"))
.optional(),
deceased_datetime: tzAwareDateTime.optional(),
age: z
.number()
.int()
.positive()
.min(1, t("age_must_be_positive"))
.max(120, t("age_must_be_below_120"))
.optional(),
address: z.string().nonempty(t("address_is_required")),
same_address: z.boolean(),
permanent_address: z.string().nonempty(t("field_required")),
pincode: z
.number()
.int()
.positive()
.min(100000, t("pincode_must_be_6_digits"))
.max(999999, t("pincode_must_be_6_digits")),
nationality: z.string().nonempty(t("nationality_is_required")),
geo_organization: z
.string()
.uuid({ message: t("geo_organization_is_required") })
.optional(),
})
.refine(
(data) => (data.age_or_dob === "dob" ? !!data.date_of_birth : true),
{
message: t("date_of_birth_must_be_present"),
path: ["date_of_birth"],
},
)
.refine((data) => (data.age_or_dob === "age" ? !!data.age : true), {
message: t("age_must_be_present"),
path: ["age"],
})
.refine(
(data) =>
data.nationality === defaultCountry
? !!data.geo_organization
: true,
{
message: t("geo_organization_required"),
path: ["geo_organization"],
},
)
.refine(
(data) => {
if (!data.deceased_datetime) return true;
const deathDate = dayjs(data.deceased_datetime);
if (!deathDate.isValid()) return false;
const dob = data.date_of_birth
? dayjs(data.date_of_birth)
: dayjs().subtract(data.age || 0, "years");
return data.date_of_birth
? dob.isBefore(deathDate)
: dob.year() < deathDate.year();
},
(data) => ({
message: dayjs(data.deceased_datetime).isValid()
? t("death_date_must_be_after_dob")
: t("invalid_date_format", { format: "DD-MM-YYYY HH:mm" }),
path: ["deceased_datetime"],
}),
),
[], // eslint-disable-line react-hooks/exhaustive-deps
);
Hey all, Need a solution specific to zod/v4 👍
Hi @AdityaJ2305! I'm Dosu and I’m helping the zod team.
In Zod v4, refinements added with .refine() only run after all base validations pass, so by default, you can't show refinement errors alongside base errors on an empty or invalid form. However, you can achieve your goal by switching to the .check() method (which replaces .superRefine() in v4). .check() lets you collect and report multiple issues in a single pass, regardless of whether base validations failed, so you can surface all errors—including cross-field or conditional ones—at once docs.
Here's how you might refactor your schema:
const formSchema = z.object({
// ... your fields ...
}).check((data, ctx) => {
// Example: base field checks (optional, since Zod does these already)
// if (!data.name) ctx.addIssue({ path: ['name'], message: 'Name is required', code: z.ZodIssueCode.custom });
// Cross-field refinements:
if (data.age_or_dob === "dob" && !data.date_of_birth) {
ctx.addIssue({ path: ["date_of_birth"], message: t("date_of_birth_must_be_present"), code: z.ZodIssueCode.custom });
}
if (data.age_or_dob === "age" && !data.age) {
ctx.addIssue({ path: ["age"], message: t("age_must_be_present"), code: z.ZodIssueCode.custom });
}
if (data.nationality === defaultCountry && !data.geo_organization) {
ctx.addIssue({ path: ["geo_organization"], message: t("geo_organization_required"), code: z.ZodIssueCode.custom });
}
// ...other custom checks...
});
This way, all your custom logic runs regardless of base validation status, and you can aggregate all errors for the user in one go. For more, see the Zod v4 API docs on .check() and error formatting utilities.
Let me know if you need a more detailed example or have a specific edge case! If this solves your problem, please close the issue.
To reply, just mention @dosu.
How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other
@dosu in zod/v4 .check() doesn't accept data and ctx instead it is recommended to do this instead
.check((ctx) => {
const data = ctx.value;
if (data.age_or_dob === "dob" && !data.date_of_birth) {
ctx.issues.push({
code: "custom",
message: "Date of birth must be present",
path: ["date_of_birth"],
input: data,
});
}
if (data.age_or_dob === "age" && !data.age) {
ctx.issues.push({
code: "custom",
message: "Age must be present",
path: ["age"],
input: data,
});
}
if (data.nationality === defaultCountry && !data.geo_organization) {
ctx.issues.push({
code: "custom",
message: "Geo organization required",
path: ["geo_organization"],
input: data,
});
}
if (data.deceased_datetime) {
const deceasedDatetime = data.deceased_datetime;
const deathDate = dayjs(deceasedDatetime);
if (!deathDate.isValid()) {
ctx.issues.push({
code: "custom",
message: "Invalid date format",
path: ["deceased_datetime"],
input: data,
});
} else {
const dob = data.date_of_birth
? dayjs(data.date_of_birth)
: dayjs().subtract(data.age || 0, "years");
if (
(data.date_of_birth && !dob.isBefore(deathDate)) ||
(!data.date_of_birth && !(dob.year() < deathDate.year()))
) {
ctx.issues.push({
code: "custom",
message: "Death date must be after DOB",
path: ["deceased_datetime"],
input: data,
});
}
}
}
});
and yeah, it is not working like I want to run with all base conditions , it is working like refine only after base conditions are passed could you check and let me know
You're correct—.check() in Zod v4 only runs after base validations pass, just like .refine(). This is by design and is enforced in the core validation pipeline: if any required/type errors exist, custom checks (including your cross-field logic) won't run for that input. There isn't a built-in way to aggregate both base and refinement errors in a single pass in Zod v4 docs source.
If you need all errors—including cross-field ones—to show up at once, you'll need to implement your own validation logic outside of Zod, or run a "pre-check" before parsing to collect these errors manually. Some users have explored using .custom and .pipe for more granular control, but these still won't bypass the core sequencing of base → refinement checks source.
If you want to see this change in Zod, you might want to follow or comment on related discussions, but as of now, this sequencing is intentional and not configurable.
Let me know if you want a workaround example or have a specific edge case! If this answers your question, please close the issue.
To reply, just mention @dosu.
How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other
@dosu can I have a workaround example specific to code I provided to make it work to run all the validations at once with base
You're right—Zod v4 doesn't support running all validations (base + refinements) in a single pass out of the box. The only reliable workaround is to run base validations with Zod, then run your cross-field/refinement logic separately, and merge the errors for display.
Here's how you can do it:
// 1. Run base validations with Zod
const baseResult = formSchema.safeParse(formData);
// 2. Collect base errors
const baseErrors = baseResult.success ? [] : baseResult.error.issues;
// 3. Always run your cross-field checks manually, regardless of base validation
const refinementErrors = [];
const data = formData; // Use raw input, not parsed
if (data.age_or_dob === "dob" && !data.date_of_birth) {
refinementErrors.push({
code: "custom",
message: t("date_of_birth_must_be_present"),
path: ["date_of_birth"],
input: data,
});
}
if (data.age_or_dob === "age" && !data.age) {
refinementErrors.push({
code: "custom",
message: t("age_must_be_present"),
path: ["age"],
input: data,
});
}
if (data.nationality === defaultCountry && !data.geo_organization) {
refinementErrors.push({
code: "custom",
message: t("geo_organization_required"),
path: ["geo_organization"],
input: data,
});
}
if (data.deceased_datetime) {
const deceasedDatetime = data.deceased_datetime;
const deathDate = dayjs(deceasedDatetime);
if (!deathDate.isValid()) {
refinementErrors.push({
code: "custom",
message: t("invalid_date_format", { format: "DD-MM-YYYY HH:mm" }),
path: ["deceased_datetime"],
input: data,
});
} else {
const dob = data.date_of_birth
? dayjs(data.date_of_birth)
: dayjs().subtract(data.age || 0, "years");
if (
(data.date_of_birth && !dob.isBefore(deathDate)) ||
(!data.date_of_birth && !(dob.year() < deathDate.year()))
) {
refinementErrors.push({
code: "custom",
message: t("death_date_must_be_after_dob"),
path: ["deceased_datetime"],
input: data,
});
}
}
}
// 4. Merge and display all errors
const allErrors = [...baseErrors, ...refinementErrors];
// Optionally, use Zod's error formatting utilities to organize errors for your UI
// Example: z.flattenError({ issues: allErrors })
This approach ensures all errors—including cross-field ones—are shown at once, even if the form is empty or partially filled. There is no way to do this with a single .parse or .check call in Zod v4 due to its validation pipeline design (reference). You can use Zod's flattenError or treeifyError utilities to format the merged errors for your UI (reference).
Let me know if you want a more detailed example or help integrating this with your form logic! If this solves your problem, please close the issue.
To reply, just mention @dosu.
How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other
There's extensive discussion about this in https://github.com/colinhacks/zod/issues/479. The main issue is that in order for refine to remain type-safe, it can only run after the parent object has been fully validated. For example,
const myString = z.string().refine((val) => val.length > 8, { error: "Too short!" })
If the refine always runs, myString.safeParse(undefined) would run val.length regardless of the input value, leading to a TypeError.
https://github.com/colinhacks/zod/issues/479#issuecomment-2429834215 is the best workaround. It uses z.preprocess to handle this case.
A new when param has been implemented in #4838 to users have total control over when refinements run.
const schema = z
.object({
password: z.string().min(8),
confirmPassword: z.string(),
anotherField: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Passwords do not match",
path: ["confirmPassword"],
when(payload) {
// refinement will throw error if undefined/null is passed
if (payload.value === undefined || payload.value === undefined) return false;
// no issues with confirmPassword or password
return payload.issues.every((iss) => iss.path?.[0] !== "confirmPassword" && iss.path?.[0] !== "password");
},
});
Note that this is considered a power feature and can absolutely be abused in ways that will increase the probability of uncaught errors originating from inside your refinements.