zod icon indicating copy to clipboard operation
zod copied to clipboard

Problems with .superRefine using discriminated unions

Open Hucxley opened this issue 1 year ago • 2 comments

I'm new to Zod, but I seem to be having an issue where something is breaking one of the schemas that I'm using for a discriminated union. The code below is causing an error in the final line (the error states that (type at position 1 of source isn't compatible with position 1 of target):

// Anchor event case
const anchorEventTimeRange = z
	.object({
		timeRange: z.literal("Anchor"),
		yearsToInclude: z.array(z.string()),
		anchorEvent: anchorEvent,  // a base object with properties preDurationMonth and postDurationMonth, among others
		trialPopulation: z.object({
			displayName: z.string().min(1, { message: "Participant Population is required." }),
			url: z.string(),
			guid: z.string(),
		}),
		alterControlPostCosts: z.string().min(1, { message: "Cost-Capped Modification is required." }),
		minimumEligibleDays: z.number().min(0, { message: "Must be a positive integer." }),
	})
	.superRefine((values, context) => {
		const monthsPre = values.anchorEvent.preDurationMonth;
		const monthsPost = values.anchorEvent.postDurationMonth;
		const monthsPrePostMin = monthsPre < monthsPost ? monthsPre : monthsPost;
		if (monthsPrePostMin > 0 && values.minimumEligibleDays > 30 * monthsPrePostMin) {
			context.addIssue({
				message: `Must be less than or equal to ${30 * monthsPrePostMin}`,
				code: z.ZodIssueCode.custom,
				path: ["minimumEligibleDays"],
			});
		}
	});

// Year over year event case
const yearOverYearTimeRange = z.object({
	timeRange: z.literal("YOY"),
	anchorEvent: anchorEvent.optional(),
	yearsToInclude: z
		.array(z.string())
		.length(2, { message: "Select 2 years to use for Year Over Year comparison." })
		.refine(
			function (val) {
				const valNumeric = val.map(item => parseInt(item));
				valNumeric.sort((a, b) => a - b);
				return valNumeric[1] - valNumeric[0] === 1;
			},
			{ message: "Selected years must be consecutive." },
		),
	trialPopulation: z.object({
		displayName: z.string().min(1, { message: "Participant Population is required." }),
		url: z.string(),
		guid: z.string(),
	}),
	isPopulationCompatible: z.boolean({ coerce: true }).nullable(),
	alterControlPostCosts: z.string().nullable(),
	minimumEligibleDays: z
		.number({ message: "Must be a positive integer." })
		.min(0, { message: "Must be a positive integer." })
		.max(365, { message: "Must be less than or equal to 365." }),
});

const timeRangeUnion = z
	.discriminatedUnion("timeRange", [yearOverYearTimeRange, anchorEventTimeRange]);
	

If I try to use this, then I get a red line under anchorEventTimeRange in the discriminated union. If I move the .superRefine from the anchorEventTimeRange definition to after the discriminated union definition (append it to the end of the final line), it will allow the union to form, but the validation errors aren't consistently firing the correct max value.

To give more info about what I'm doing, in the year over year case, the max value for minimumEligibleDays is 365. However, if it is an anchor event case, then max values of minimumEligibleDays is (30 * the lesser of anchorEvent.preDurationMonth or anchorEvent.postDurationMonth).

Any ideas about what is going wrong with this that causes the discriminated union objects to not be compatible? I've tried adding a .superRefine to both of objects for each case, but that causes the year over year case to have an error that it is missing a bunch of properties that are truncated, and if I add it to the anchor event case as shown above, then I get the error mentioned above.

Hucxley avatar Aug 21 '24 19:08 Hucxley

Temporarily use union to implement it for the time being. discriminatedUnion does not support ZodEffects. #2441

abnerwei avatar Aug 22 '24 10:08 abnerwei

But the larger issue then is that we have 2 different schema definitions that rely on which value is present in the timeRange field. If "YOY" => use yearOverYearTimeRange, if "Anchor" => use anchorEventTimeRange. In my application, changing to .union does remove the error, but then when I try to fill out the form now, I'm getting errors in the YOY rules while using an anchorEvent entry type. It appears to only be applying the rules from the yearOverYearTimeRange schema.

I think I'll go back to the buggy behavior I had before and see if I can tidy that up for now because I can't be throwing errors for the wrong type of form that users can't resolve.

Hucxley avatar Aug 22 '24 14:08 Hucxley

😿

MrSucik avatar Jan 19 '25 06:01 MrSucik

we need this as discriminated unions only checks a specific schema rather checking against all. It would be powerful to have refinements over the validations on this discriminated union

Amansaxena001 avatar Jan 22 '25 08:01 Amansaxena001

Hi @Hucxley! I'm Dosu and I’m helping the zod team.

You're running into this because .union tries all schemas and reports errors from any that don't match, so you can get errors from the wrong schema when the discriminant field isn't set up right. That's why you see YOY errors when filling out an Anchor form.

With .discriminatedUnion, Zod uses the timeRange field to pick the right schema, but it can't handle schemas wrapped in .superRefine (or other ZodEffects) as union members—these wrappers hide the discriminator key, so Zod can't route validation correctly. This is a design tradeoff in Zod and is still the case in Zod 4, even after recent changes like pipe support (source, source).

The best workaround is to define your discriminated union without .superRefine on the members. Parse the input with the union, then run your custom validation logic (like your minimumEligibleDays rule) on the parsed result, based on the timeRange value. This way, you only run the extra checks after Zod has picked the right schema.

Example:

const timeRangeUnion = z.discriminatedUnion("timeRange", [
  yearOverYearTimeRange,
  anchorEventTimeRange,
]);

function validateTimeRange(input) {
  const parsed = timeRangeUnion.safeParse(input);
  if (!parsed.success) return parsed;

  const data = parsed.data;
  if (data.timeRange === "Anchor") {
    const monthsPre = data.anchorEvent.preDurationMonth;
    const monthsPost = data.anchorEvent.postDurationMonth;
    const monthsPrePostMin = Math.min(monthsPre, monthsPost);
    if (monthsPrePostMin > 0 && data.minimumEligibleDays > 30 * monthsPrePostMin) {
      // Add your custom error here
      return {
        success: false,
        error: {
          issues: [
            {
              path: ["minimumEligibleDays"],
              message: `Must be less than or equal to ${30 * monthsPrePostMin}`,
            },
          ],
        },
      };
    }
  }
  // ...add YOY custom logic if needed
  return parsed;
}

This pattern keeps validation errors relevant to the selected form type and avoids the UX issue of showing users errors they can't resolve.

For more details, see the discussion in #4856 and the Zod 4 migration notes.

If you have a minimal, self-contained reproduction or need more help, feel free to share it. 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  Join Discord Share on X

dosubot[bot] avatar Jul 21 '25 22:07 dosubot[bot]