zod icon indicating copy to clipboard operation
zod copied to clipboard

Feature: Conditional Validation Similar to yup.when()

Open ckifer opened this issue 3 years ago • 8 comments

Similar to this Yup issue: https://github.com/jquense/yup/issues/176 and creating a new issue out of #61.

Many users of zod would like to do conditional requirement or validation of fields based on either fields passed as context to zod, or based off of the value of another schema property. This is common to do in form validations.

The closed issue #61 addresses how to do conditional validation with superRefine. This works well enough in small cases, but many folks have large complex schemas and we would like to keep the existing validation on an object i.e. not have to make the entire object partial in order for conditional requirement to work.

This issue stems from the reaction to my comment https://github.com/colinhacks/zod/issues/61#issuecomment-1171958123

Pasting from the comment as my case is the same: I have large schema that needs to require one or another field. A nested partial Zod object with superRefine works for this.

What I want to be able to do however, is to do conditional requirement on 1 or 2 fields without making the entire object partial and while having access to all of the fields in the object.

Ex: I have a required enum that has two values: "ValueA" and "ValueB"

Upon "ValueA" then someOtherFieldA is required. Upon "ValueB" then someOtherFieldB is required.

There are also other required fields within the schema that should remain required without explicitly checking them in something like superRefine.

The reason I chose zod over yup was TypeScript support, but in yup's beta versions TS support has improved a lot. Not having a functionality like this is a big blocker for me wanting to keep using zod. Thanks!

ckifer avatar Sep 08 '22 20:09 ckifer

This is currently the only problem I have with zod - I have to do conditional field validation in superRefine which is not the problem but then if I want to use that schema in other places with extend() or merge() the problem starts.

I think that something like yup.when() is the only missing part in this schema validation library.

snax4a avatar Sep 22 '22 16:09 snax4a

Another possible solution for this issue is Joi's xor() method. Would look like this:

const schema = z.object({
  field1: z.string(),
  field2: z.number(),
}).xor(['field1', 'field2']); // only one of these fields can be present/non-nullish

But when() would be more universal.

Some other possible solutions from different libraries:

  • Laravel has rules required_with:another_field, required_without:another_field, and required_if:another_field,value.
  • ClassValidator has @ValidateIf((object) => !object.someField)

andrew-sol avatar Oct 25 '22 13:10 andrew-sol

+1

413n avatar Nov 19 '22 12:11 413n

@colinhacks almost at 90 +1s here. Is something like this even possible in zod? I know you have mentioned elsewhere that it isn't trivial. Anything that you can comment on that might give us an idea if it could even be a feature in the future?

ckifer avatar Dec 01 '22 00:12 ckifer

I'm joining this thread to provide more requirements than just requiring a if b.

My use case is that I have an optional startDate and endDate that need to create a valid date range (if provided), but can both remain null. If either is filled, the other becomes required and startDate needs to be a date before endDate.

I have achieved this in Yup thanks to when, inside which I also use a custom test. Very verbose, but it is possible.

deniskabana avatar Dec 02 '22 09:12 deniskabana

@deniskabana can you provide a code snippet and how you'll like it to be ideally?

nicoabie avatar Dec 02 '22 12:12 nicoabie

I wrote a bit of untested code using Yup for you since I have never worked with Zod before. I wanted to dive in deep today - I read the official website's docs and a few tutorials, took a look at the APIs and then I started googling for my exact use-case.

  Yup.date()
    .when(["startDate", "endDate"], ([startDate, endDate]: [Date, Date], schema) => {
      if (!startDate && !endDate) return schema;
      if (startDate && !endDate) return schema.required(errors.validation.dateRange.required);
      if (!startDate && endDate) return schema.required(errors.validation.dateRange.required);

      return schema.required().test("date-range-test", errors.validation.dateRange.startBeforeEnd, () => {
        // Check if both are dates object
        if (!("getMonth" in startDate) || !("getMonth" in endDate)) return false;

        const monthFrom = startDate.getMonth();
        const monthTo = endDate.getMonth();
        if (monthFrom < monthTo) return true;
        if (monthFrom > monthTo) return false;
        return startDate.getDate() < endDate.getDate();
      });
    });

This is verbose, has no type checkings, but it works (I suppose - I haven't tested this specific snippet).

deniskabana avatar Dec 02 '22 13:12 deniskabana

@deniskabana definitely a valid case imo. Ideally we would have access to the rest of the schema, access to some sort of context external to the schema (if passed), and would be able to change the validation on more than just one field at a time while maintaining the validations that have been untouched. The biggest problem with superRefine in Zod is that you have to make the object partial and re-add every existing validation (essentially making the original validations useless).

ckifer avatar Dec 02 '22 16:12 ckifer

This isn't going to happen in Zod, sorry folks. I find the .when API very unaesthetic and it adds nothing in terms of type safety. There's no other scenario where Zod has you specify object keys with strings; it's hacky and looks bad. It breaks locality. The reason Zod is easy to learn is because it's clean and compositional. There's no reason a child schema in a ZodObject should behave differently based on its sibling fields, ever.

without making the entire object partial

This was never necessary, I was just trying to simplify the example code I provided in #61. You need to make the two relevant fields optional then add an appropriate refinement.

const myObject = z
  .object({
    first: z.string().optional(),
    second: z.string().optional(),
  })
  .refine(
    data => !!data.first || !!data.second,
    'Either first or second should be filled in.',
  );

If you want slightly more accurate union typing, declare a base type, create your variants, and union them together.

const baseObject = z.object({
  // whatever
});

const mySchema = baseObject
  .extend({ first: z.string() })
  .or(baseObject.extend({ second: z.string() }))

To solve the case where different fields are required based on an enum key:

z.object({
  key: z.enum(['first', 'second']),
  first: z.string().optional(),
  second: z.string().optional()
}).refine(val => {
  if(val.key === 'first') return !!val.first;
  if(val.key === 'second') return !!val.second;
  return true;
})

There's no scenario I see where a when API improves on this in terms of type safety, readability, or performance.

colinhacks avatar Dec 12 '22 07:12 colinhacks

@colinhacks Thank you for your response! I understand your point about Yup's when API being messy. It really is.

But now as I'm working with the date range values as in the example I provided, I don't see how to make a refinement easy and readable, that does the following:

  • Start and end dates must both be null or filled
  • If filled, start date needs a Date object pointing to a date before the end date
  • There are daily opening and closing times for each event (full 7-day week) specified like. I left these out from the previous example, but I feel these might further complicate the use of .refine() here
  • Opening and closing times follow rules 1 and 2 - opening and closing for the same day must both be null or filled and opening must be before closing

I can validate this easily on the server with custom logic. But it would make much more sense UX-wise if this was easier to implement on the frontend as well for immediate user feedback.

deniskabana avatar Dec 12 '22 07:12 deniskabana

Thanks for the response @deniskabana. The problem is that these refinements shouldn't live inside the ZodDate instance. They should get attached to the ZodObject schema that contains startDate and endDate. Let me know if I'm still misunderstanding.

const schema = z
    .object({
      startDate: z.date().optional(),
      endDate: z.date().optional(),
      actualDate: z.date(),
    })
    .refine((val) => {
      if (!val.startDate && !val.endDate) return true;
      if (!val.startDate || !val.endDate) return false;
      return val.actualDate >= val.startDate && val.actualDate <= val.endDate;
    });

Note that you actually have typing on startDate and endDate because this approach doesn't try to break locality, unlike the Yup example, in which Yup has no idea what the type should be on those values. Way less verbose and more typesafe. Use superRefine if you want to customize error codes or messages.

You can also define this refinement as a separate function and drop it into multiple schemas.

  const dateRangeValidator = (val: {
    startDate?: Date;
    endDate?: Date;
    actualDate: Date;
  }) => {
    if (!val.startDate && !val.endDate) return true;
    if (!val.startDate || !val.endDate) return false;
    return val.actualDate >= val.startDate && val.actualDate <= val.endDate;
  };
  
  const schema = z
    .object({
      startDate: z.date().optional(),
      endDate: z.date().optional(),
      actualDate: z.date(),
    })
    .refine(dateRangeValidator);

colinhacks avatar Dec 12 '22 07:12 colinhacks

@colinhacks Thank you for that response with examples, that really cleared the situation for me!

To further clarify for you and others, Yup does check the date type, so the actual schema for the example I have shown would be Yup.date().required().when(...) but I ended up with 300 lines of code for form validation with Yup. Ouch. After decoupling some of the validations to functions and referencing those, it was still almost 200 lines of code. Hence, I sympathize with your opinion about how unaesthetic the when API is in Yup.

The second example of yours clears this very well. There is no need to use superRefine other than for the error messages. Have a great day!

deniskabana avatar Dec 12 '22 08:12 deniskabana

Thanks for the response @colinhacks! I wasn't too hopeful, but thank you for more example and clarity. Much appreciated!

ckifer avatar Dec 12 '22 15:12 ckifer

It would be nice if there was a way to pass in context or something to refine. For another example, I have one validator that needs to make a server request (to check if the name has been used before), but that request needs another that I have access to when I create the form. But for the sake of reusability, I want to be able to make that validator a function that can be used in different contexts.

leximarie avatar Mar 13 '23 23:03 leximarie

@leximarie you can create a function that accepts some arguments and returns a function for the refine/superRefine.

Usage example:

z.string().superRefine(myValidator('some arg'))

asologor avatar Mar 13 '23 23:03 asologor

@asologor but where do you pass in the argument? (I'm attempting to use zod with react-hook-form)

leximarie avatar Mar 13 '23 23:03 leximarie

@leximarie dunno. You said you have access to that other argument when you create a form. Just think whether it's suitable in your case. Maybe it's not. You don't have too many options with Zod here.

asologor avatar Mar 13 '23 23:03 asologor

There's no other scenario where Zod has you specify object keys with strings; it's hacky and looks bad. It breaks locality.

@colinhacks it seems as though the the path option of .refine() would meet that description, wouldn't it?

From the docs:

const passwordForm = z
  .object({
    password: z.string(),
    confirm: z.string(),
  })
  .refine((data) => data.password === data.confirm, {
    message: "Passwords don't match",
    path: ["confirm"], // <-- path of error
  });

Or the Discriminated union?

const myUnion = z.discriminatedUnion("status", [ // <-- 
  z.object({ status: z.literal("success"), data: z.string() }),
  z.object({ status: z.literal("failed"), error: z.instanceof(Error) }),
]);

0livare avatar Jul 19 '23 17:07 0livare

There's no reason a child schema in a ZodObject should behave differently based on its sibling fields, ever.

@colinhacks I strongly disagree, can you please elaborate on this?

Stripe has many apis that have mutually exclusive fields and subfields that are dependant on the value of sibling fields. For example, to create a Price object, one of unit_amount or custom_unit_amount is required, unless billing_scheme=tiered.

I realize this could be achieved using refine or superRefine but that doesn't feel like a great solution. That's what I've just done and why I'm commenting here. It makes it harder to reuse and extend our schemas because refinements must be kept separate. It also doesn't contribute to a pit of success IMHO.

MarkMurphy avatar Aug 22 '23 13:08 MarkMurphy