colinhacks / zod

TypeScript-first schema validation with static type inference
https://zod.dev
MIT License
33.92k stars 1.18k forks source link

Problems with .superRefine using discriminated unions #3720

Open Hucxley opened 2 months ago

Hucxley commented 2 months ago

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.

abnerwei commented 2 months ago

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

Hucxley commented 2 months ago

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.