colinhacks / zod

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

How to dinamically enable/disable optional parts of a schema? #1597

Open OnkelTem opened 1 year ago

OnkelTem commented 1 year ago

I have quite a regular situation when one edits more than one group of fields on a form. And depending on some conditions (f.e. checkbox states), those groups can be displayed/hidden and correspondingly - should be validated/skipped.

Consider this simple form:

image

So we enter first and last names, and if we check "More information", a new part appears with two additional fields: email and address.

Here is a sandbox: https://codesandbox.io/s/zod-problem-forked-r7xmcm?file=/src/index.ts

There are two base schemas:

const group1Schema = z.object({
  firstName: z.string(),
  lastName: z.string()
});

const group2Schema = z.object({
  email: z.string().email(),
  address: z.string()
});

And one with their combination for the form:

const schema = z.object({
  group1: group1Schema,
  group2: group2Schema.optional(),
  needGroup2: z.boolean()
});

The problem is that now it's not possible to submit this form w/o the second (optional) part.

There is no form in this sandbox (to keep it minimal) but there is the defaultValues const which imitates the corresponding property of the ReactHookForm useForm() hook.

As you see, you cannot even initialize such a form with empty values because they get validated and validation doesn't pass. Of course, this is expected behaviour, granted how I declared the schemas.

Hence my question: how to conditionally enable/disable parts of the schema?

This form should pass to its submit handler the following:

1) if only first and last name entered and the checkbox is off:

{
 group1: {
   firstName: "value",
    lastName: "value"
  },
  needGroup2: false // or underfined
}

2) if all values are entered and the checkbox is on:

{
 group1: {
    firstName: "value",
    lastName: "value"
  },
  group2: {
    email: "value@domain.com",
    address: "Some address..."
  },
  needGroup2: true
}

3) IMPORTANT if all values are entered, but the checkbox is OFF, the group2 should be discarded, and the result should be equal to the 1st reply:

{
 group1: {
    firstName: "value",
    lastName: "value"
  },
  needGroup2: true
}

If you have any other ideas on how to solve such tasks, please share.

maxArturo commented 1 year ago

Hey @OnkelTem, I was actually just working on a method that might help you out here. Try this out with discriminatedUnions and see if it works for you (I demoed with your use cases from your sandbox):

import z from "zod";

const group1Schema = z.object({
  firstName: z.string(),
  lastName: z.string()
});

const group2Schema = z.object({
  email: z.string().email(),
  address: z.string()
});

const group1Validator = z.object({
  group1: group1Schema,
  type: z.literal('groupOne') // this could be true/false/whatever you want
});

const group2Validator = z.object({
  group1: group1Schema,
  group2: group2Schema,
  type: z.literal('groupTwo')
});

const schema = z.discriminatedUnion('type', [group1Validator, group2Validator])

type Schema = z.infer<typeof schema>;

schema.parse({
  group1: {
    firstName: "value",
    lastName: "value"
  },
  type: 'groupOne'
});

schema.parse({
  group1: {
    firstName: "value",
    lastName: "value"
  },
  group2: {
    email: "value@domain.com",
    address: "Some address..."
  },
  type: 'groupTwo'
})

schema.parse({
  group1: {
    firstName: "value",
    lastName: "value"
  },
  group2: {
    email: "value@domain.com",
    address: "Some address..."
  },
  type: 'groupOne'
})

console.log('success')

EDIT: re this

the group2 should be discarded

zod will strip out unrecognized keys by default so you should be good here

Roundaround commented 1 year ago

@maxArturo Unfortunately I think that solution will start to break down pretty quickly once you start adding more groups, because you'll need to define a "validator" for every possible combination of optional parts.

I had to do something similar, where my schema could optionally be extended with extra bits and I solved it using intersections with unions. The short of it is that the optional part is represented as a union of nothing (or in this case, useGroup2: false) with the full object (useGroup2: true, group2: {...}).

const group1Schema = z.object({
  group1: z.object({
    firstName: z.string(),
    lastName: z.string(),
  }),
});

const group2Schema = z.union([
  z.object({
    useGroup2: z.literal(false),
  }),
  z.object({
    useGroup2: z.literal(true),
    group2: z.object({
      email: z.string().email(),
      address: z.string(),
    }),
  }),
]);

const fullSchema = group1Schema.and(group2Schema);

https://www.typescriptlang.org/play?#code/JYWwDg9gTgLgBAbzgLzgXzgMyhEcBEyEAJvgNwCwAUNQMYQB2AzvAOY4CuYAjAMq0ALAKYgAhnAC8KAHQQARgCshtGAAoE1OHHYQu3AFwz5Sles1aswKCwByokEMPJpLKMAatVASgA05rQA2orb2jjKu7p6+5mjRVLGUNFT0zGycYABM-MJikjIcDMCMqgDa5s7GymoaVBZwHExCAOLpGU7SAcAwQlCiAaqYfY1xWrF+tUaKVWYTWg3Nre2d3b39MFAcQiMWOlxtkybV-hYiosAB7REe3tKn597jdVqixMRQQkxMl+uRD8djMTiAF0vIk6IwWFgOAEAtlTnldjw4WJpKIGMRVIisoJTqDqNQYABPMBCOAAMWhATJ0DwUmc7kwPQAPESSRBMFCYcjRAA+RIAen5FgAegB+fHJCHwTA0gBqfU2AEE8phKdzpGBRNYhDMtIiDIhjpgrCEHIYAOQACSEMIg5seFiCprC5oA6tAAsR7TFxgkJSlITKoCB5QFNgAhFVqnEozXa3XadIGmp1Y3WGB2M1wK02gJ2h2BYIZ0IW91QT3eiZoB3zFq6TKGdabB1Ywwpk5ic4WwYAayEdwCAAFWF0BBw5NJ6CBK3UXm8Pl9s9wMgBmOAALRIcAAMmihDP0L68VQgA

EDIT:

When you want to check for the existence of group2 in this example, you would do something like:

if (parsedData.useGroup2) {
  // Type inference kicks in and you now have access to parsedData.group2
  console.log(parsedData.group2.email);
}
maxArturo commented 1 year ago

@Roundaround Yep that makes sense! That's a cleaner way of doing it for sure. @OnkelTem I'd go with their suggestion.

OnkelTem commented 1 year ago

Thank you folks for your answers.

I don't think those discriminated/union methods would work at all. Because you need to initialize the form, right? And you cannot do this with discriminators or unions.

Here is a sandbox with the @Roundaround 's approach:

https://codesandbox.io/s/proud-thunder-yg1xdu?file=/src/App.tsx

image

So the idea is to still keep some default values for the form, even if useGroup2 is false.

Roundaround commented 1 year ago

I'm not familiar with this form library, but from what I gather, everything in the defaultValues object gets sent in the submit invocation, right? So it should only reflect your actual default (i.e. useGroup2: false and no group2 defined). If you still want to add default values to those dynamic inputs, it looks like you can define them inline on the input itself via the defaultValue prop instead of the big object at the beginning. That being said, if your default value actually is just empty string, I'm pretty sure you can just omit it entirely.

JacobWeisenburger commented 1 year ago

@OnkelTem Has this issue been resolved? Or do you have any other questions?

I'd like to close this issue if there are no further questions.

emahuni commented 1 year ago

I have a somewhat similar issue that i thought was straight forward in Zod. I thought I was going to see something like this for the above question (just changed a few things to illustrate the straightforwardness of what I was thinking):

schema = z.object({
  firstName: z.string(),
  lastName: z.string(),
  moreInfo: z.boolean(),
  email: z.string().email().when(z.object({ moreInfo: z.literal(true) })),
  address: z.string().when(z.object({ moreInfo: z.literal(true) })),
})

// usage is simple, just use parse as usual.
schema.parse(data)

I thought Zod could provide a conditional of some sort that way. It's readable and cheap to write. email and address are optional, but are changed to required when that inner schema passes.

The above could be done in a modular way:

const moreInfoSchema = z.object({ moreInfo: z.literal(true) });

const schema = z.object({
  firstName: z.string(),
  lastName: z.string(),
  moreInfo: z.boolean(),
  email: z.string().email().when(moreInfoSchema),
  address: z.string().when(moreInfoSchema),
})

// usage is simple, just use parse as usual.
schema.parse(data)

In addition to when, there could also be otherwiseWhen and otherwise; these are just if, else-if and else and should work in a similar fashion.

stale[bot] commented 1 year ago

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.

toxsick commented 1 year ago

Hey guys, you are trying to achieve something similar to #1394, but @colinhacks has expressed his understandable concerns (https://github.com/colinhacks/zod/issues/1394#issuecomment-1346013265) with something like .when.

I have the same problem though and find the proposed solutions pretty complicated so far (given that the scenario comes up pretty often in more complex forms). I would also like to find an elegant way of defining conditional parts in the schema, but this seems pretty hard right now.

Just for reference: #1422 could also be interesting in this context.

Pkcarreno commented 1 year ago

I think what @emahuni says makes a lot of sense, although passing a function would be easier to handle and a more elegant solution.

schema = z.object({
  firstName: z.string(),
  lastName: z.string(),
  moreInfo: z.boolean(),
  email: z.string().email().when((data) => data.moreInfo),
  address: z.string().when((data) => data.moreInfo && data.firstName === 'Joe'), // would also allow some more complex logic to be entered.
})

schema.parse(data)

I also think that using isRequired instead of when is more explicit to the objective of the function.

schema = z.object({
  firstName: z.string(),
  lastName: z.string(),
  moreInfo: z.boolean(),
  email: z.string().email().isRequired((data) => data.moreInfo),
  address: z.string().isRequired((data) => data.moreInfo && data.firstName === 'Joe'),
})

schema.parse(data)
ealmansi commented 1 year ago

You can enable/disable parts of the schema dynamically using z.lazy.

import { z } from 'zod';

const myState = {
  moreInformation: false,
};

const mySchema = z.lazy(() => {
  return z.object({
    firstName: z.string(),
    lastName: z.string(),
    ...(myState.moreInformation ? {
      email: z.string().email(),
      address: z.string(),
    } : {})
  });
});

myState.moreInformation = false;
console.log(
  mySchema.safeParse({
    firstName: "Jane",
    lastName: "Doe",
  }).success ? "OK" : "Not OK",
) // OK

myState.moreInformation = true;
console.log(
  mySchema.safeParse({
    firstName: "Jane",
    lastName: "Doe",
  }).success ? "OK" : "Not OK",
) // Not OK

myState.moreInformation = true;
console.log(
  mySchema.safeParse({
    firstName: "Jane",
    lastName: "Doe",
    email: "jane@doe.com",
    address: "123 Zod St.",
  }).success ? "OK" : "Not OK",
) // OK
Pkcarreno commented 1 year ago

hey @ealmansi thanks, thats good to know

I use the z.discriminatedUnion function for this kind of situations too

Like this:

const schema = z.discriminatedUnion('status', [
  z.object({ status: 'status one' }).extend(statusOneSchema),
  z.object({ status: 'status two' }).extend(statusTwoSchema),
  z.object({ status: 'status three' }).extend(statusThreeSchema),
  z.object({ status: 'status four' }).extend(statusFourSchema),
])
yaziciahmet commented 1 year ago

Here is what I like to do:

const userSchema = z.object({
  firstName: z.string(),
  lastName: z.string(),
  email: z.optional(z.string().email()),
  address: z.optional(z.string()),
});

const requiredUserSchema = userSchema.extend({
  email: userSchema.shape.email.unwrap(),
  address: userSchema.shape.address.unwrap(),
});

This way, I can define all the rules in a single place, and just unwrap the optional fields if I need them to be required in another place.

stale[bot] commented 11 months ago

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.