valibot icon indicating copy to clipboard operation
valibot copied to clipboard

[✨Feature Request]: Add similar functionality like `yup.when()`

Open xsjcTony opened this issue 1 year ago • 38 comments

Hey, thanks for the great library.

As a validation library, .when() API is always a great way to do conditional validating.

However, this is not supported in zod (https://github.com/colinhacks/zod/issues/1394) (look at how many 👍 there are), and seems the zod team is 300% 👎 for building this... What a pity😥

As I jumped into it for a while, I totally understand that it's pretty hard to maintain the type-safety if this is implemented, but idk if there's a way to do so. As I said, in the end we are validating libraries, of course type-safety is important, but I do think providing such user-friendly API would be much more important.

Currently, I'm using zod, and I'd like to switch to valibot. When migrating from yup to zod, the lack of .when() almost killed me☠...

I know it's kind of doable via the pipeline in valibot, just like refine() / superRefine() in zod, but it's still a huge pain... Both are more of a workaround instead of a proper solution.

Feel free to say that this is impossible or you are not willing to build that, that's totally fine, as I know the where's the difficulty under the hood, but yeah, what if there's a chance that the dream comes true😂 Thanks💚

If there's any duplicating issues already, please feel free to link them and close this one, as I searched when but nothing appears to be there.

xsjcTony avatar Sep 05 '23 12:09 xsjcTony

Thank you for creating this issue. I will try to investigate a when method next week. This issue could also be related to https://github.com/fabian-hiller/valibot/issues/5.

fabian-hiller avatar Sep 05 '23 13:09 fabian-hiller

Hi! Having a .when would be awesome! it would simplify the logic to define conditional rules or rules that depend on another field. It would be ✨Awesome✨ to know the position that valibot would take regarding the .when implementation

demarchenac avatar Sep 06 '23 13:09 demarchenac

This enhancement also seems to be exactly what i have been looking for here - https://github.com/fabian-hiller/valibot/issues/120

ziyak97 avatar Sep 06 '23 22:09 ziyak97

Here's an idea I had to add a workaround of .when() as a pipeline method for the object schema, as an example a schema with when would look like:

const schema = object(
  {
    firstName: string([
      minLength(3, "Needs to be at least 3 characters"),
      endsWith("cool", "Needs to end with `cool`"),
    ]),
    lastName: nullish(string()),
  },
  [
    when({
      field: "lastName",
      dependsOn: "firstName",
      constraint: ({ lastName }) => lastName?.length > 0  ?? false,
    }),
  ]
);

Maybe we could re-write this into something similar to:

const schema = object({
  firstName: string([
    minLength(3, "Needs to be at least 3 characters"),
    endsWith("cool", "Needs to end with `cool`"),
  ]),
  lastName: when(string(), {
    dependsOn: "firstName",
    constraint: ({ lastName }) => lastName?.length > 0  ?? false,
  }),
});

demarchenac avatar Sep 07 '23 04:09 demarchenac

@demarchenac thank you for your feedback on this. I will think about it.

fabian-hiller avatar Sep 07 '23 04:09 fabian-hiller

I know it might be hard, but it would be really good if we could append another transform based on the condition just like in yup sometimes we need to omit a specific field based on certain condition, so there won't be a value (because it's still filled in the input element) in the payload.

E.g.

type IDType = 'driver_licence' | 'passport';

const schema: ObjectSchema<IdDetailsFormSchema> = yup.object({
  primary_id_type: yup.string<'driver_licence' | 'passport'>().required(),
  driver_licence_number: yup.string()
    .when('primary_id_type', {
      is: (value: IDType): boolean => value === 'driver_licence',
      then: schema => schema.required(),
      otherwise: schema => schema.transform(() => void 0)
    })
});

This way, even user still has driver's license number input filled in, it won't be included in the final payload because it's been transformed into undefined.

This is just a potential add-on, which is good to have. 💚

xsjcTony avatar Sep 07 '23 04:09 xsjcTony

yup.when() basically splits the schema for that specific field into two branches, where all following constraints / transforms are sticking with that branch only.

Based on @demarchenac 's idea, we might add another transform option to perform such task, but I'm not sure if this is a good idea because if it's different on the high-level (i.e. schema does not spilt into different branch), it would be a nightmare to add all available further actions as object properties. e.g.

lastName: when(string(), {
  dependsOn: "firstName",
  constraint: ({ lastName }) => lastName?.length > 0  ?? false,
  transform: // ...
  // ...: ...
}),

xsjcTony avatar Sep 07 '23 04:09 xsjcTony

I took a closer look at Yup's .when. I understand that Colin does not like the API and has not implemented it in Zod yet. But on the other hand I see the DX advantages of such an API. So I have been thinking about how we can make this API sexy for Valibot. Below are two examples:

// Validate based on a sibling
const Schema1 = object({
  type: enumType(['user', 'admin']),
  token: string([
    when('sibling', (input) => input?.type === 'admin', {
      then: startsWith('admin_'),
      else: startsWith('user_'),
    }),
  ]),
});

// Validate based on a child
const Schema2 = object(
  {
    type: enumType(['user', 'admin']),
    token: string(),
  },
  [
    when('child', (input) => input.type === 'admin', {
      then: custom((input) => input.token.startsWith('admin_')),
      else: custom((input) => input.token.startsWith('user_')),
    }),
  ]
);

Instead of a single validation function, it should be possible to pass a pipeline of functions to then and else. What do you think of this idea? I appreciate any feedback.

fabian-hiller avatar Sep 10 '23 04:09 fabian-hiller

Yeah, this does look good to me, but when using the second case, or not only for the when(), this might happen to all Valibot object pipelines. The later pipeline is not executed if something fails in the earlier pipeline.

https://github.com/colinhacks/zod/issues/2524 This is an known issue in zod, and even affecting those libs based on zod https://vee-validate.logaretm.com/v4/integrations/zod-schema-validation/

I'm not sure if this is intended behaviour... as both sides have their valid point, maybe consider about this. This is one of the issue when() wants to resolve I think.

xsjcTony avatar Sep 10 '23 10:09 xsjcTony

The later pipeline is not executed if something fails in the earlier pipeline.

Are you sure about that? We revised this a few weeks ago.

Edit: Thanks to this comment, I now understand what you mean.

fabian-hiller avatar Sep 10 '23 16:09 fabian-hiller

Disclaimer: just came across this during my research, not very familiar with valibot, only considering it for our project; just wanted to insert my 2 cents. What also would be great is option to have 'when'-like feature to augment the schema outside of its definition, e.g.:

const schema = augmented(
  object(
    {
      auth: enumType(['pass', 'oauth']),
    }
  ),
  [
    when('child', (obj) => obj.auth === 'pass', {
      then: object({ login: string(), password: string() }),
    }),
    when('child', (obj) => obj.auth === 'oauth', {
      then: object({ bearer: string() }),
    }),
  ]
)
/*
scema :: {
  auth: 'pass' | 'oauth',
  login: 'string' | undefined,
  password: 'string' | undefined,
  bearer: 'string' | undefined
}
*/

Bad object schema for storing app state, useful when dealing with APIs. I don't think yup can even do this?

Karakatiza666 avatar Sep 10 '23 18:09 Karakatiza666

@Karakatiza666 you should use union or variant in this case:

// Normal union
const Schema1 = union([
  object({
    auth: literal('pass'),
    login: string(),
    password: string(),
  }),
  object({
    auth: literal('oauth'),
    bearer: string(),
  }),
]);

// Discriminated union
const Schema2 = variant('auth', [
  object({
    auth: literal('pass'),
    login: string(),
    password: string(),
  }),
  object({
    auth: literal('oauth'),
    bearer: string(),
  }),
]);

fabian-hiller avatar Sep 10 '23 18:09 fabian-hiller

@fabian-hiller It sounds really helpful for most co-dependent input field validations that I can think of! But I do agree with @xsjcTony, it seems that the pipeline methods passed on to the object schema only seems to run if all of the schema fields are valid. I don't know if this is a behavior that can be changed though since it seems to be the default behavior. Also, I did see that this is already a known issue.

Lastly, regarding the syntax of your proposal for the when method. It looks awesome ✨ I want to read more about this! Thanks for your great work!

P.S: I do see myself using the first syntax more often than the second one

demarchenac avatar Sep 10 '23 22:09 demarchenac

Thank you for your feedback. Do we even need the second when('child', ... solution?

Is ist hard wo make both APIs typesafe. My plan is to type input at when('sibling', ... as Record<string, unknown>.

fabian-hiller avatar Sep 10 '23 22:09 fabian-hiller

Yeah in all the scenarios I'm currently working on the validations are more like the first example of when('sibling', ...) based.

And I agree with it being typed as Record<string, unknown>, the developer should know what he's doing, or we as developer should be allowed to pass an expected type for it.

demarchenac avatar Sep 10 '23 22:09 demarchenac

The most common examples that I've been working on are sibling based validations:

const SomeSchema = object({
    ...fields
}, [
    fieldRequiredWhen(
        {
            field: { key: 'someField', is: (field) => Boolean(field && field.length > SOME_VALUE /* e.g. 6 */) },
            dependsOn: { key: 'sibling' }
        },
        'This field is required'
    ),
    fieldRequiredWhen(
        {
            field: { key: 'otherField' },
            dependsOn: { key: 'otherSibling', is: (otherSibling) => otherSibling.length >= SOME_LENGTH /* e.g. 8 */ }
        },
        'This field is required'
    ),
])

demarchenac avatar Sep 10 '23 22:09 demarchenac

Also, I thought of a grouped validation for some fields sharing the required validation when a sibling has some value:

const SomeSchema = object({
    ...fields
}, [
    fieldsRequiredWhen(
        {
            fields: ['dependsOnSibling', 'alsoDependsOnSibling', ...dependantFields, 'lastFieldDependingOnSibling'],
            dependsOn: { key: 'sibling', is: (sibling) => sibling === 'some value' }
        },
        'This field is required'    // or something along those lines.
    )
])

So, I can definitively see the power that the when method allows us to implement really flexible schemas out of the box.

demarchenac avatar Sep 10 '23 22:09 demarchenac

The only issue that I currently have with the provided examples above are the runtime issues in which theses validations are only visible to a user if all of the fields validations are successful.

demarchenac avatar Sep 10 '23 22:09 demarchenac

Thank you for your feedback and contribution! I'll give it some thought over the next few days.

fabian-hiller avatar Sep 11 '23 02:09 fabian-hiller

Yeah I totally agree with all the above, and the real difficulty here is to make it type-safe (and I guess that's why zod doesn't want to implement it), so unknown should do the job, where developers should be responsible for what they are doing.

The only issue that I currently have with the provided examples above are the runtime issues in which these validations are only visible to a user if all of the fields validations are successful.

And yeah... this is worrying me. Thanks for the collection.💚

xsjcTony avatar Sep 11 '23 07:09 xsjcTony

I like the idea I proposed in https://github.com/fabian-hiller/valibot/issues/76#issuecomment-1682271279

Code example
const schema = object({
  name: string(),
  password: string(),
  confirmPassword: string(),
}, [
  {
    type: 'pre', // Run before the validation
    transform: (data: unknown) => data.password === data.confirmPassword, // Input will be unknown
  },

  {
    type: 'post', // Run after validation (default)
    transform: (data: Typed) => data.password === data.confirmPassword, // Input will be typed

    // Allow specifying path and error message instead of throwing validation error
    path: ["confirmPassword"],
    message: "Both password and confirmation must match",
  },

  // Existing pipes are supported. Are the same as 'post' pipes
  custom((data) => data.password === data.confirmPassword),

  {
    // Object schema only
    type: 'partial', // Run after successfull validation of specified fields
    fields: ['password', 'confirmPassword'], // Array elements can be typed
    transform: (data: Pick<Typed, 'password' | 'confirmPassword'>) => data.password === data.confirmPassword, // Input will contain specified fields
  },
])

The only thing Valibot needs is to support are partial (that run then part of the object schema is valid) pipelines and other proposals in this thread (fieldsRequiredWhen and when) could be implemented using them. And all this can be type safe.

What I don't like about non-pipeline solution, is that it is, by definition, cannot be typed. And it requires one field to know the existence of another, that should be the parent schema concern.

Demivan avatar Sep 11 '23 11:09 Demivan

I would be interested to hear what others think about @Demivan's idea.

fabian-hiller avatar Sep 11 '23 15:09 fabian-hiller

@Demivan By non-pipeline solution are you referring to the one that's not within the pipeline for the object schema? I'm asking since both examples from @fabian-hiller are within a pipeline, the difference being the type of pipeline since the first one is within an string schema and the second one is within an object schema

demarchenac avatar Sep 11 '23 15:09 demarchenac

@demarchenac yes, you are correct. It is not about pipeline/not-pipeline, but a sibling vs parent pipeline. And sibling one, I don't think, aligns with valibot API design.

Demivan avatar Sep 11 '23 16:09 Demivan

I agree that the sibling pipeline is a little bit weird in terms of the original API design. My main concern is the ability to run the object parent pipeline even if the schema validation turns out invalid, which is partial thing mentioned above.

xsjcTony avatar Sep 12 '23 09:09 xsjcTony

i am not a fan of @Demivan idea. i like the original when child solution proposed it's easy to understand.

ziyak97 avatar Sep 12 '23 10:09 ziyak97

So there are a few things on my mind:

  1. The sibling when is easy to understand, but it's very hard (or impossible?) to ensure the type safety there, so unknown will be used and developers need to type guard it, which might be awkward (yup uses any🤣 in this case). ⚠️And this is supposed to be run once the specific field schema is valid (who is using when), not the whole object schema.
  2. Will the parent when going to run even if the object schema validation fails?
  3. The partial idea from above should still be implemented if 2 is negative since this will be a huge issue in form validation.

xsjcTony avatar Sep 13 '23 01:09 xsjcTony

@Karakatiza666 you should use union in this case:

const Schema = union([
  object({
    auth: literal('pass'),
    login: string(),
    password: string(),
  }),
  object({
    auth: literal('oauth'),
    bearer: string(),
  }),
]);

@fabian-hiller There is a semantic problem with this approach: when a derived field fails validation (e.g. bearer minLength isn't satisfied) corresponding union branch fails; now another branch is tested, and field 'auth' with value 'oauth' doesn't match the constraint auth: literal('pass'). In effect, both auth and bearer fail validation (which is confusing in context of user form).

Karakatiza666 avatar Sep 18 '23 15:09 Karakatiza666

Thanks for the hint. For this we probably need something like discriminatedUnion or switch (related to issue #90).

fabian-hiller avatar Sep 18 '23 16:09 fabian-hiller

discriminatedUnion is very useful in form validation with different options

unlinking avatar Oct 05 '23 12:10 unlinking