How can I use zod to conditionally validate a form with nested fields?

56 views Asked by At

I'm trying to conditionally validate a react-hook-form that looks like this with zod:

enter image description here

The features of the validation are:

  1. The user must tick at least one contact method, or they see an error
  2. They must then provide the value for the contact method they've ticked
  3. The values are then refined and transformed (eg. trimmed, parsed into the right format etc)

My schema looks like this:

const schema = z
  .object({
    // other fields...
    contactMethods: z
      .array(z.enum(["email", "phone"]), {
        invalid_type_error: "At least one contact method is required",
      })
      .min(1, "At least one contact method is required"),
    email: z
      .string({ invalid_type_error: "Enter an email1" })
      .trim()
      .email("Enter a valid email"),
    phone: z
      .string({ invalid_type_error: "Enter a phone number" })
      .trim()
      .transform(val => val.replaceAll(" ", "")) // spaces confuse validator function below
      .refine(val => validator.isMobilePhone(val), "Enter a valid phone number") // check value is parsable as phone number
      .transform(val => parsePhoneNumber(val, "GB")?.number) // parse into +44 e.164 format if not already
  })
  .refine(vals => vals.contactMethods.includes("email") ? vals.email : true, { message: "Enter an email", path: ["email"] })
  .refine(vals => vals.contactMethods.includes("phone") ? vals.phone : true, { message: "Enter a phone number", path: ["phone"] })

I'm trying to adapt the solution in this closed zod issue.

This doesn't do the job because:

  • The form has a few other fields (omitted here for clarity), and I need to make sure they pass validation before triggering the .refine() blocks further down. At the moment, the user can resolve what they think are all the errors, and then see more once they finish.
  • Error messages for the nested fields hang around once I've started typing in one of them, even if I untick that contact method

I know zod supports union OR types, which seems helpful here in principle, but I'm struggling to understand how to adapt the code to my situation.

Also open to suggestions for simplifying this code more generally.

1

There are 1 answers

0
adsy On

One way to do this would be to split the schema down into three parts. The first part checks there is at least one contact method, but it doesn't care data all about email or phone.

This is then intersected with two other schemas. One to handle email and one to handle phone. Each of those schemas is a union of the two possibilities for each respective one:

  • phone/email is not in contact methods.
  • phone/email is in contact methods, and phone/email is valid.

The whole thing comes together in a single schema.

const contactMethods = z.enum(['email', 'phone'])
const schema = z
  .object({
    // other fields...
    contactMethods: z
      .array(contactMethods, {
        invalid_type_error: 'At least one contact method is required',
      })
      .min(1, 'At least one contact method is required'),
  })
  .and(
    z.union([
      z.object({
        contactMethods: z
          .array(contactMethods)
          .refine((val) => val.includes(contactMethods.Enum.email)),
        email: z
          .string({ invalid_type_error: 'Enter an email1' })
          .trim()
          .email('Enter a valid email'),
      }),
      z.object({
        contactMethods: z
          .array(contactMethods)
          .refine((val) => !val.includes(contactMethods.Enum.email)),
      }),
    ]),
  )
  .and(
    z.union([
      z.object({
        contactMethods: z
          .array(contactMethods)
          .refine((val) => val.includes(contactMethods.Enum.phone)),
        phone: z
          .string({ invalid_type_error: 'Enter a phone number' })
          .trim()
          .transform((val) => val.replaceAll(' ', '')) // spaces confuse validator function below
          .refine((val) => validator.isMobilePhone(val), {
            message: 'Enter a valid phone number',
          }) // check value is parsable as phone number
          .transform((val) => parsePhoneNumber(val, 'GB')?.number), // parse into +44 e.164 format if not already
      }),
      z.object({
        contactMethods: z
          .array(contactMethods)
          .refine((val) => !val.includes(contactMethods.Enum.phone)),
      }),
    ]),
  )