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

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

Open
OnkelTem opened this issue Nov 24, 2022 · 14 comments
Open

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

OnkelTem opened this issue Nov 24, 2022 · 14 comments
Labels
stale No activity in last 60 days

Comments

@OnkelTem
Copy link

OnkelTem commented Nov 24, 2022

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
}
  1. 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
}
  1. 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
Copy link
Contributor

maxArturo commented Nov 24, 2022

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
Copy link

Roundaround commented Nov 24, 2022

@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
Copy link
Contributor

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

@OnkelTem
Copy link
Author

OnkelTem commented Nov 26, 2022

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
Copy link

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
Copy link
Contributor

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

@JacobWeisenburger JacobWeisenburger added the closeable? This might be ready for closing label Jan 2, 2023
@emahuni
Copy link

emahuni commented Jan 6, 2023

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.

@JacobWeisenburger JacobWeisenburger removed the closeable? This might be ready for closing label Jan 7, 2023
@stale
Copy link

stale bot commented Apr 7, 2023

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.

@stale stale bot added the stale No activity in last 60 days label Apr 7, 2023
@toxsick
Copy link

toxsick commented May 2, 2023

Hey guys, you are trying to achieve something similar to #1394, but @colinhacks has expressed his understandable concerns (#1394 (comment)) 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.

@stale stale bot removed the stale No activity in last 60 days label May 2, 2023
@Pkcarreno
Copy link

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
Copy link

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
Copy link

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
Copy link

yaziciahmet commented Jun 22, 2023

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
Copy link

stale bot commented Sep 21, 2023

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.

@stale stale bot added the stale No activity in last 60 days label Sep 21, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
stale No activity in last 60 days
Projects
None yet
Development

No branches or pull requests

9 participants