On-Demand Required Form Fields with Zod & React Hook Form

Having type-safe and flexible forms can sometimes be tricky, especially when dealing with dynamic field requirements. In this case, we have a height field with two inputs for the Imperial system and a switch that, when toggled, changes the input to a single cm field for the Metric system. The problem arises when we need to make the height field required—but only for the selected unit.
Visual example


Now, imagine this is just one step in a large multi-step form—for example, one step out of nine. If we make all fields required, users won’t be able to submit the form unless they fill in both height fields, even for the unselected unit.
One way to handle this would be to make the height fields optional and then check if at least one has data before allowing submission. However, this approach quickly becomes problematic when dealing with large forms with multiple dynamic conditions. Since we want to use Zod for robust and simplified validation, this workaround is not ideal.
Fortunately, there's a better and more robust way to handle this using Zod, but it took me quite some time to figure out the correct setup. I came across various forum discussions where people had similar issues, but most solutions were either unclean or ineffective.
That's why, in this article, I’ll show you how to handle this easily using best practices with Zod & React Hook Form.
Code example
Basic Profile Information
export const userFormSchema = z.object({
firstName: nameSchema,
lastName: nameSchema,
email: emailSchema.optional(),
birthDate: z.string({ message: 'Date of birth is required' }).refine(
(date) => {
const selectedDate = new Date(date);
const currentDate = new Date();
return selectedDate < currentDate;
},
{
message: 'Please select a past date',
}
),
})
Including a basic example below for better reference on how to combine dynamic validation.
On-Demand Height Validation
.and(
z.union([
z.object({
heightUnit: z.literal('in'),
heightFt: z
.number({ message: 'Height in ft is required' })
.min(1, { message: 'Minimum height is 1ft' })
.max(11, { message: 'Maximum height is 7ft and 11in' }),
heightIn: z
.number()
.max(11, { message: 'Maximum height is 7ft and 11in' })
.optional(),
}),
z.object({
heightUnit: z.literal('cm'),
heightCm: z
.number({ message: 'Height in cm is required' })
.min(61, { message: 'Minimum height is 61cm' })
.max(301, { message: 'Maximum height is 301cm' }),
}),
])
)
The union()
is used to define multiple possible schemas, allowing for dynamic validation. By using and()
, you can combine conditions that are applied based on the value of heightUnit
.
When heightUnit
is in, the schema enforces validation on heightFt
and optionally on heightIn
.
When heightUnit
is cm, the schema enforces validation on heightCm
.
This dynamic approach allows the form to switch between validation rules and make fields required depending on the selected unit.
Why This Approach?
Only the selected unit's fields are required, preventing unnecessary validation errors.
Uses z.union()
to ensure mutually exclusive validation (only one system at a time).
Keeps the schema type-safe, concise, and scalable—ideal for large multi-step forms.
This method makes form validation dynamic, robust, and efficient, ensuring a smooth user experience.
Some people suggested using
refine
with conditions to check the unit and make the field required, but I’ve tried it—even withsuperRefine
— and it doesn’t work well in this case.
This is how you can simply and effectively manage such cases without compromising code quality or functionality. If you know of a better way to handle these scenarios, feel free to share—but for now, this is the best solution I've found. Thanks for reading! I hope this was helpful—let me know your thoughts!
Below is a React Hook Form code example in case you're wondering and need more details.
Contact Me
Have an opportunity, wanna collaborate on something cool or just say hello!
Send Email