Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: Conditional Validation Similar to yup.when() #1394

Closed
ckifer opened this issue Sep 8, 2022 · 19 comments
Closed

Feature: Conditional Validation Similar to yup.when() #1394

ckifer opened this issue Sep 8, 2022 · 19 comments

Comments

@ckifer
Copy link

ckifer commented Sep 8, 2022

Similar to this Yup issue: jquense/yup#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 #61 (comment)

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!

@snax4a
Copy link

snax4a commented Sep 22, 2022

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.

@andrew-sol
Copy link

andrew-sol commented Oct 25, 2022

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)

@413n
Copy link

413n commented Nov 19, 2022

+1

@ckifer
Copy link
Author

ckifer commented Dec 1, 2022

@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?

@deniskabana
Copy link

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.

@nicoabie
Copy link

nicoabie commented Dec 2, 2022

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

@deniskabana
Copy link

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).

@ckifer
Copy link
Author

ckifer commented Dec 2, 2022

@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).

@colinhacks
Copy link
Owner

colinhacks commented Dec 12, 2022

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.

@deniskabana
Copy link

@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.

@colinhacks
Copy link
Owner

colinhacks commented Dec 12, 2022

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);

@deniskabana
Copy link

@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!

@ckifer
Copy link
Author

ckifer commented Dec 12, 2022

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

@leximarie
Copy link

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.

@asologor
Copy link

@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'))

@leximarie
Copy link

leximarie commented Mar 13, 2023

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

@asologor
Copy link

@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.

@0livare
Copy link
Contributor

0livare commented Jul 19, 2023

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) }),
]);

@MarkMurphy
Copy link

MarkMurphy commented Aug 22, 2023

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.

Repository owner locked as resolved and limited conversation to collaborators Aug 22, 2023
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

No branches or pull requests