valibot
valibot copied to clipboard
[✨Feature Request]: Add similar functionality like `yup.when()`
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.
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.
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
This enhancement also seems to be exactly what i have been looking for here - https://github.com/fabian-hiller/valibot/issues/120
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 thank you for your feedback on this. I will think about it.
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. 💚
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: // ...
// ...: ...
}),
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.
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.
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.
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 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 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
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>
.
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.
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'
),
])
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.
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.
Thank you for your feedback and contribution! I'll give it some thought over the next few days.
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.💚
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.
I would be interested to hear what others think about @Demivan's idea.
@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 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.
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.
i am not a fan of @Demivan idea. i like the original when child solution proposed it's easy to understand.
So there are a few things on my mind:
- The sibling
when
is easy to understand, but it's very hard (or impossible?) to ensure the type safety there, sounknown
will be used and developers need to type guard it, which might be awkward (yup
usesany
🤣 in this case). ⚠️And this is supposed to be run once the specific field schema is valid (who is usingwhen
), not the whole object schema. - Will the parent
when
going to run even if the object schema validation fails? - The
partial
idea from above should still be implemented if2
is negative since this will be a huge issue in form validation.
@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).
Thanks for the hint. For this we probably need something like discriminatedUnion
or switch
(related to issue #90).
discriminatedUnion is very useful in form validation with different options