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

RFC: add the possibility to validate with Zod schema #21

Open
Epimodev opened this issue May 9, 2023 · 6 comments
Open

RFC: add the possibility to validate with Zod schema #21

Epimodev opened this issue May 9, 2023 · 6 comments

Comments

@Epimodev
Copy link
Contributor

Epimodev commented May 9, 2023

The goal of this issue is finding the best way to add form validation with Zod.

Why using Zod for form validation

Zod is schema declaration and validation library containing a lot of built in validators.
It makes also combining several validators easily and we can setup generic error message and set specific error messages if needed.

Suggested update

I think we can add Zod schema validation without any breaking change by:

  • in useForm, add a second parameter validationSchema of type ZodType<Values>

It let the user use the old validation API by field. But validationSchema will overwrite validate set by field.
Edit: To make the API opinionated and avoid user to think about different way to validate and have inconsistencies in the codebase, we'll remove the current API (and create a breaking change).

How to handle different validation use case we have at Swan:

Basic validation

const validationSchema = z.object({
  name: z.string().min(1, 'Required'), // required field
  surname: z.string(), // optional field
  age: z.number().min(18), // validate a field containing a number
  email: z.string().min(1, 'Required').email(),
})

Custom validation

const validationSchema = z.object({
  IBAN: z.custom<string>((value) => isIban(value)),
})

Edit:
To be able to use reusable custom validation without rewrite the error message each time, we'll use superRefine like this:

// function we could export in a separate file as `validation.ts` to be able to import in several forms
const refineIban = (value: string, ctx: z.RefinementCtx) => {
  if (!isValidIban(value)) {
    ctx.addIssue({
      code: "custom",
      message: "Invalid IBAN",
    });
  }
};

const schema = z.object({
  iban: z.string().superRefine(refineIban),
});

Validation of a field depending on another field

source: https://medium.com/@mobile_44538/conditional-form-validation-with-react-hook-form-and-zod-46b0b29080a3

const validationSchema = z.object({
  firstName: z.string(),
  lastName: z.string(),
}).superRefine(({ firstName, lastName }, ctx) => {
  // Makes last name required only if first name is provided
  if (firstName.length > 0 && lastName.length === 0) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: "Last name is required",
      path: ["lastName"],
    })
  }
})

Async validation of a field

const isIban = (value: unknown) => true
const validateIbanOnServerSide = async (value: unknown) => Promise.resolve(false)

const validationSchema = z.object({
  IBAN: z.custom(isIban).refine(validateIbanOnServerSide, "This IBAN doesn't exist")
})

Error messages

By default Zod generate error messages depending on the schema declaration, but they are only in english and might not be user friendly.

First way (not recommanded): set error message in schema declaration for each field

The technically easiest way to fix this is setting custom message in all validators but it will be very painful to maintain and if we miss one case, the user will see error messages with a bad ux-writting.

Better way but not idea: Global error map

https://zod.dev/ERROR_HANDLING?id=global-error-map
Zod gives the possibility to set ErrorMap to transform default error message to translated messages with a custom ux-writting.
We can set it globally but if we use Zod for other kind of validation this isn't ideal.

Even better solution: add the possibility to set error map in react-ux-form

https://zod.dev/ERROR_HANDLING?id=contextual-error-map
Zod gives the possibiliy to set error map when we parse the input. As parsing will be done in react-ux-form, I think we could expose a function like setZodErrorMap giving the possibility to set error map for all our forms (we could call this function in utils/i18n.ts for example)

import { setZodErrorMap } from "react-ux-form";

const formErrorMaps = {
  en: {},
  fr: {},
  de: {},
  // ...
}

setZodErrorMap(formErrorMaps[locale])
@Epimodev
Copy link
Contributor Author

After thinking about validation with Zod I thought about partial validation.
Once again Zod is amazing and provide a very easy way to get only the validator of a specific key like this:

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


const result = await validationSchema.shape.firstName.safeParseAsync(values.firstName)

But there is one limitation: if we use superRefine to create validation depending on another field, shape isn't available anymore and it makes sense because we need all values to validate 1 single field.

At the moment the only idea I had is:
1️⃣ if we don't use superRefine for validation, we use validationSchema.shape.${fieldName} exactly as we did with validate parameter
2️⃣ if we use superRefine we always validate all fields with those caveats

  • if there are async validation, we should provide a way to memoize/dedupe, otherwise each validation run by any change will take too much time
  • we'll not be able to use different strategy for each field because we must validate all fields together

@zoontek
Copy link
Collaborator

zoontek commented May 15, 2023

@Epimodev I'm not sure about the superRefine API. Executing validation of all fields, all the time could be really expensive.

Maybe we could replace this:

schema: z
  .object({
    firstName: z.string(),
    lastName: z.string(),
  })
  .superRefine(({ firstName, lastName }, context) => {
    // Makes last name required only if first name is provided
    if (firstName.length > 0 && lastName.length === 0) {
      context.addIssue({
        code: z.ZodIssueCode.custom,
        message: "Last name is required",
      });
    }
  })

With an API like this:

// values is a Proxy object (or it could be a function to get the value)
schema: values =>
  z.object({
    firstName: z.string(),
    lastName: z
      .string()
      .refine(
        value => values.firstName.length > 0 && value.length === 0,
        "Last name is required",
      ),
  })

@Epimodev
Copy link
Contributor Author

Yes I agree revalidate all fields will be expensive and might make inputs laggy.
But I don't understand how your solution avoid this problem. Because if we change firstName, how can we know we should also revalidate only lastName and not other fields?

@zoontek
Copy link
Collaborator

zoontek commented May 15, 2023

@Epimodev Hmmm, indeed this does not solve the issue either.

@Epimodev
Copy link
Contributor Author

I checked Zod returns different type of object if we add refine:

  • z.string() returns ZodString
  • z.string().refine() returns ZodEffects<z.ZodString, string, string>
    So we could know what field depends on other fields or not. But this isn't perfect in case we have several refine depending on different values.

@Epimodev
Copy link
Contributor Author

According to last discussion we had about validation depending on other values, maybe the best solution will be adding a new param validationDependency which is an array of other fields names. And in the case I think it will be better to keep validate in field config like this:

const {} = useForm({
  firstName: {
    initialValue: "",
    validationDependency: ["lastName"],
    validate: ({ lastName }) => z.string().refine(
        value => values.firstName.length > 0 && value.length === 0,
        "First name is required", // only if last name is empty
      ),
  },
  lastName: {
    initialValue: "",
  }
})

The challenge here is making validationDependency type depending on other fields and validate param typed depending on validationDependency.

What do you think?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants