Conditional validation #2099
-
Hey guys! I was thinking that it might be interesting to implement conditional validations. I'll explain it better with some example... const animal = z
.object({
type: z.nativeEnum(AnimalType),
name: z.string(),
wings: z.number().optional(), // required if type is bird
legs: z.number().optional(), // required if type is bird or mammal
})
.superRefine((schema, ctx) => {
const { type, wings, legs } = schema;
if (type === AnimalType.BIRD) {
if (!wings) {
//ctx.addIssue to say wings is required
}
if (!legs) {
//ctx.addIssue to say legs is required
}
} else if (type === AnimalType.MAMMAL) {
if (!legs) {
//ctx.addIssue to say legs is required
}
}
});
enum AnimalType {
MAMMAL = 'MAMMAL',
BIRD = 'BIRD',
FISH = 'FISH',
} When we have a small amount of conditions it might be ok, but once our logic grows it starts being a bit complex and "dirty". I was thinking that it could be simplified if we create some sort of conditional function that makes every further function dependant to that conditional check. For example: const animal = z
.object({
type: z.nativeEnum(AnimalType),
name: z.string(),
wings: z.number().optional().conditional((schema) => schema.type === AnimalType.BIRD).required(), // required if type is bird
legs: z.number().optional().conditional((schema) => schema.type === AnimalType.BIRD || schema.type === AnimalType.MAMMAL).required(), // required if type is bird or mammal
}) Everything after Hope you find this interesting, I am glad to answer any question if you need. |
Beta Was this translation helpful? Give feedback.
Replies: 6 comments 6 replies
-
Is this what you are looking for? const props = {
name: z.string(),
wings: z.number(),
legs: z.number(),
}
const typeEnum = z.enum( [ 'mammal', 'bird', 'fish' ] )
const mammalSchema = z.object( {
type: z.literal( typeEnum.enum.mammal ),
name: props.name,
legs: props.legs,
} )
const birdSchema = z.object( {
type: z.literal( typeEnum.enum.bird ),
name: props.name,
legs: props.legs,
wings: props.wings,
} )
const fishSchema = z.object( {
type: z.literal( typeEnum.enum.fish ),
name: props.name,
} )
const animal = z.discriminatedUnion( 'type', [
mammalSchema,
birdSchema,
fishSchema,
] ) Additional resources: If you found my answer satisfactory, please consider supporting me. Even a small amount is greatly appreciated. Thanks friend! 🙏 |
Beta Was this translation helpful? Give feedback.
-
I think that you have the wrong way of thinking. In a typescript project you have to declarate type and interface for objects. During write code you have to write an interface for object and rules for form validator. You make similar work twice. Example: you have form for updating user address. User address has conditional attributes, depends from address is for personal or company. You have to write a interface of types for user address, and after that you have to write validator for html form. Could it only be done once? Resolution: Zod // Default props in personal address and company address
const defaultProps = z.object({
is_company: z.boolean(),
firstname: z.string().min(1),
lastname: z.string().min(1),
street: z.string().min(1),
city: z.string().min(1),
postalcode: z.string().min(3),
country_id: z.number().min(1, 'Please select country')
})
// If user mark that this is a company adress, user have to write company name and tax_number
const companySchema =z.object({
is_company: z.literal(true),
company_name: z.string().min(1),
tax_number: z.string().min(1),
});
// Personal address do not have to write company address and tax_number, because user mark is_company as false
const personalSchema = z.object({
is_company: z.literal(false),
})
// We mark union depends of is_company attribute
const schemaCond = z.discriminatedUnion(
'is_company', [
companySchema,
personalSchema
]);
// We intersection with default properties
const ValidatorSchema = z.intersection(schemaCond, defaultProps);
// Infer type for typescript
type AddressType = z.infer<typeof ValidatorSchema>;
// Validate
try {
const addressCompany: AddressType = ValidatorSchema.parse({
is_company: true,
company_name: "g431",
tax_number: "g143",
firstname: "fdsfsd",
lastname:"fds",
street:"fsd",
city:"Fds",
postalcode:"fds",
country_id:4
})
console.log(addressCompany);
const addressPersonal: AddressType = ValidatorSchema.parse({
is_company: false,
// company_name: "g",
// tax_number: "g",
firstname: "fdsfsd",
lastname:"fds",
street:"fsd",
city:"Fds",
postalcode:"fds",
country_id:4
})
console.log(addressPersonal);
}catch(err){
console.log(err);
} |
Beta Was this translation helpful? Give feedback.
-
And here is how you can easily make a form with steps validation using zod enum FormStepEnum {
FIRST = 1,
SECOND = 2,
THIRD = 3,
}
const defaultSchema = z.object({
_step: z.nativeEnum(FormStepEnum),
});
const firstStepSchema = z.object({
_step: z.literal(FormStepEnum.FIRST),
name: z.string().nonempty(),
last_name: z.string().nonempty(),
});
const secondStepSchema = z.object({
_step: z.literal(FormStepEnum.SECOND),
mom_name: z.string().nonempty(),
dad_name: z.string().nonempty(),
});
const thirdStepSchema = z.object({
_step: z.literal(FormStepEnum.THIRD),
grandpa_name: z.string().nonempty(),
grandma_name: z.string().nonempty(),
});
const schemaConditions = z.discriminatedUnion('_step', [
firstStepSchema,
secondStepSchema,
thirdStepSchema,
]);
const validationSchema = z.intersection(schemaConditions, defaultSchema);
type FormValidationSchema = z.infer<typeof validationSchema>;
const form = useForm<FormValidationSchema>({
resolver: zodResolver(validationSchema),
mode: 'onChange',
defaultValues: { _step: FormStepEnum.FIRST },
}); and then just manually set form step when you want form.setValue('_step', FormStepEnum.SECOND) |
Beta Was this translation helpful? Give feedback.
-
Hi there,
This would be useful when the same form is doing two different operations, such as create and edit. Thanks :) |
Beta Was this translation helpful? Give feedback.
-
One example using export const jobPostFormSchema = z
.object({
applyOption: z.string(),
companyDescription: z.string(),
companyName: z.string().min(1, { message: i18n.t('forms.validations.required.companyName') }),
department: z.string().min(1, { message: i18n.t('forms.validations.required.department') }),
description: z.string().min(1, { message: 'Description is required' }),
employmentType: z.string().min(1, { message: i18n.t('forms.validations.required.employmentType') }),
location: z.string().min(1, { message: i18n.t('forms.validations.required.location') }),
salary: z.number().optional(),
status: z.boolean(),
url: z.string(),
email: z.string(),
title: z.string().min(1, { message: i18n.t('forms.validations.required.title') }),
workplacePolicy: z.string().min(1, { message: i18n.t('forms.validations.required.workplacePolicy') }),
})
// Perform conditional validation to ensure either a valid email or url is provided.
.superRefine(({ email, url, applyOption }, refinementContext) => {
if (applyOption === 'url') {
if (url === '') {
return refinementContext.addIssue({
code: z.ZodIssueCode.custom,
message: i18n.t('forms.validations.required.url'),
path: ['url'],
});
}
if (!getIsValidUrl(url)) {
return refinementContext.addIssue({
code: z.ZodIssueCode.custom,
message: i18n.t('forms.validations.invalid.url'),
path: ['url'],
});
}
}
if (applyOption === 'email') {
if (email === '') {
return refinementContext.addIssue({
code: z.ZodIssueCode.custom,
message: i18n.t('forms.validations.required.email'),
path: ['email'],
});
}
if (!getIsValidEmail(email)) {
return refinementContext.addIssue({
code: z.ZodIssueCode.custom,
message: i18n.t('forms.validations.invalid.email'),
path: ['email'],
});
}
}
return refinementContext;
}); |
Beta Was this translation helpful? Give feedback.
-
Hello.. I am having a similar issue with Zod. I am kinda using a step in a component and in case the API returns and error with an email we show the first and last name fields to allow for registration.. I used to use Yup and context from RHF and pass it conditionally with The solution I ended up for now, is to put some optional fields in loginSchema. But that's a bit dumb isn't it? Any other ideas? Thanks! The Component for reference.. import { useMemo, useState } from "react";
import type { FocusEvent } from "react";
import { useForm } from "react-hook-form";
import { useTranslations } from "next-intl";
import Image from "next/image";
import Box from "@mui/joy/Box";
import Card from "@mui/joy/Card";
import FormControl from "@mui/joy/FormControl";
import FormHelperText from "@mui/joy/FormHelperText";
import FormLabel from "@mui/joy/FormLabel";
import Input from "@mui/joy/Input";
import Typography from "@mui/joy/Typography";
import { zodResolver } from "@hookform/resolvers-next/zod";
import { z } from "zod";
import { useSendOtp } from "#generated/apollo";
import { NO_LATIN_CHARS } from "#src/constants/regexes";
import { cn } from "#src/utils/cn";
const defaultSchema = z.object({
_step: z.enum(["LOGIN", "SIGNUP"]).default("LOGIN"),
});
const loginSchema = z.object({
_step: z.literal("LOGIN"),
email: z
.string()
.min(1, { message: "FORM.ERRORS.REQUIRED" })
.email({ message: "FORM.ERRORS.INVALID_EMAIL" }),
firstName: z.string().optional(),
lastName: z.string().optional(),
});
const signupSchema = z.object({
_step: z.literal("SIGNUP"),
email: z
.string()
.min(1, { message: "FORM.ERRORS.REQUIRED" })
.email({ message: "FORM.ERRORS.INVALID_EMAIL" }),
firstName: z
.string()
.min(1, { message: "FORM.ERRORS.REQUIRED" })
.regex(NO_LATIN_CHARS, { message: "FORM.ERRORS.INVALID_CHARS" }),
lastName: z
.string()
.min(1, { message: "FORM.ERRORS.REQUIRED" })
.regex(NO_LATIN_CHARS, { message: "FORM.ERRORS.INVALID_CHARS" }),
});
const schemaPermutations = z.discriminatedUnion("_step", [
loginSchema,
signupSchema,
]);
const schema = z.intersection(schemaPermutations, defaultSchema);
type Schema = z.infer<typeof schema>;
export const Form = () => {
const [step, setStep] = useState<"SIGNUP" | "LOGIN">("LOGIN");
const t = useTranslations("LOGIN");
const [sendOTP] = useSendOtp();
const { register, handleSubmit, formState } = useForm<Schema>({
defaultValues: { ...schema.safeParse, _step: "LOGIN" },
resolver: zodResolver(schema),
});
const errors = useMemo(() => formState.errors, [formState]);
const handleEmailBlur = async (event: FocusEvent<HTMLInputElement>) => {
const email = event.target.value;
if (email.length === 0) return;
await sendOTP({
variables: { email },
onCompleted: () => {
setValue("_step", "LOGIN");
},
onError: () => {
setValue("_step", "SIGNUP");
},
});
};
const handleLogin = handleSubmit(async () => {});
return (
<Box className="mx-auto flex size-full flex-col items-end justify-end md:max-w-xl md:items-center md:justify-center">
<Card variant="plain" className="grid w-full gap-12 p-10 px-8">
<Image src="/comp.svg" alt="Comp" height={32} width={120} />
<Box className="grid gap-3">
<Typography level="h3" className="break-words">
{t("FORM.TITLE")}
</Typography>
</Box>
<form id="login" className="grid gap-4" onSubmit={handleLogin}>
<FormControl error={Boolean(errors.email)}>
<FormLabel>{t("FORM.FIELDS.EMAIL")}</FormLabel>
<Input {...register("email")} onBlur={handleEmailBlur} />
<FormHelperText>
{errors.email ? t(errors.email.message as never) : null}
</FormHelperText>
</FormControl>
<Box className={cn({ hidden: step === "SIGNUP" })}>
<FormControl error={Boolean(errors.firstName)}>
<FormLabel>{t("FORM.FIELDS.FIRST_NAME")}</FormLabel>
<Input {...register("firstName")} />
<FormHelperText>
{errors.firstName ? t(errors.firstName.message as never) : null}
</FormHelperText>
</FormControl>
<FormControl error={Boolean(errors.lastName)}>
<FormLabel>{t("FORM.FIELDS.LAST_NAME")}</FormLabel>
<Input {...register("lastName")} />
<FormHelperText>
{errors.lastName ? t(errors.lastName.message as never) : null}
</FormHelperText>
</FormControl>
</Box>
</form>
</Card>
</Box>
);
}; |
Beta Was this translation helpful? Give feedback.
Is this what you are looking for?
Additional resources:
discriminated-unions
W…