One Source of Truth: Deriving Required Fields from Zod
June 2, 2025
TL;DR: Struggling to keep form validation and UI in sync in your React app? Here’s how I used Zod and React Hook Form together to define a single source of truth—driving both runtime validation and required field indicators in the UI.
The Problem: When Validation Drifts from UI
I’ve been writing forms for as long as I’ve been writing code—and lately, that’s meant building them in React. I’ve been working on a project with a fairly common set of constraints:
- Custom fields that wrap a third-party library
- React Hook Form (RHF) for form state management
- TypeScript
Because I’m using custom fields controlled by RHF, the implementation required some additional infrastructure. I wanted to define a strategy that was easy to maintain. It’s easy to start a form with good structure—a well-defined model with clearly mapped values and validation, but over time, requirements will change, and conditional logic can add unexpected complications.
This can result in surprisingly unmaintainable code. Defects creep in when developers mark something as optional in one place but required in the validation logic, or when common structures are reused across forms. React can make this easy to do unintentionally, since field rendering is often separated from runtime and submission-time validation, leading to mismatches.
One way to mitigate this is to define a single source of truth for field structure and validation.
Why Zod?
Since I’m using RHF and TypeScript, I chose Zod to serve as the single source of truth for both validation and form metadata.
Zod is a TypeScript-first library that uses schema definitions to drive runtime validation. These definitions can be simple—just field names and types—or complex, with conditionals and transformations. It is declarative, integrates cleanly with RHF, and offers runtime validation with static type inference.
Because I already had to define the fields for Zod, I wanted to see if the same schema could drive rendering logic as well. As a starting point, I focused on extracting which fields are required directly from the schema, to keep the UI and validation logic tightly aligned.
Defining the Schema
For the purpose of this post, let’s say I have a form that takes in:
- Name (required)
- Nickname (optional)
- Mailing address (Required, and must be valid)
- Contacts (1..n)
First, I define the address schema, which will be reused across the app:
import { z } from 'zod'; import { useForm } from 'react-hook-form'; const addressSchema = z.object({ addressLine1: z.string().nonempty(), addressLine2: z.string().optional(), city: z.string().nonempty(), state: z.string().nonempty(), zip: z.string().nonempty(), });
Now I can use that address schema on the form.
const mySchema = z.object({ name: z.string().nonempty(), nickname: z.string().optional(), mailingAddress: addressSchema, contacts: z.array( z.object({ name: z.string().nonempty(), phone: z.string().optional(), }) ).nonempty(), });
Defining Default Values
Because RHF uses uncontrolled inputs by default, but I am using controlled inputs, I also need to define default values to the dreaded warning: “A component is changing an uncontrolled input of type text to be controlled.”
I could hypothetically do this through Zod, but the default values do not always play as nicely with TypeScript as one would hope. This is primarily when the input needs to be defaulted as a string, but the schema definition is a number. To sidestep that, I will separately define the default values.
const getDefaultValues = () => ({ name: '', nickname: '', mailingAddress: { addressLine1: '', addressLine2: '', city: '', state: '', zip: '', }, contacts: [{ name: '', phone: '' }], });
Great, now I just need to grab the list of required fields from the schema!
… and that’s where things get surprising. I look through the Zod API to find how I can inspect the derived schema, and unfortunately, it’s not just a simple API call.
Deriving the Required Fields
It seems like I should be able to just iterate over the fields in the schema and check if they are optional. After some searching, I landed on a potential solution that uses schema traversal https://github.com/colinhacks/zod/issues/1643. However, it relies on schema._def.shape()
and also requires schema traversal for every field. This seems like a very expensive solution.
I also saw some solutions parsing the Zod schema to JSON using a parsing library. I was hesitant to add yet another third-party library, but that seemed more reliable. However, I’d have to re-parse the entire schema any time there was a conditional change in the form.
At this point, I realized I already had a tool that knows how to parse the schema. RHF runs the validation when the form is submitted or changed – I should be able to reuse this mechanism and pass in an empty object, which will return an error for every field that is required.
import { ZodTypeAny } from 'zod'; export function zGetRequiredFieldMap(schema: ZodTypeAny): Record<string, true> { const result = schema.safeParse({}); const requiredMap: Record<string, true> = {}; if (!result.success) { for (const { path } of result.error.issues) { const key = path.map(String).join('.'); requiredMap[key] = true; } } return requiredMap; }
I can just test this with a unit test since it isn’t rendering anything:
describe('zGetRequiredFieldMap', () => { it('should identify all required fields correctly', () => { const result = zGetRequiredFieldMap(mySchema); expect(result).toEqual({ name: true, email: true, 'mailingAddress.addressLine1': true, 'mailingAddress.city': true, 'mailingAddress.state': true, 'mailingAddress.zip': true, 'contacts.0.name': true, }); }); });
The flat fields are working, but the nested schema isn’t. The empty object doesn’t know about the nested schema and doesn’t know how to validate it. This is annoying. I suppose I can traverse the schema exactly once and dynamically build the object, then use that to test.
But wait, I had already defined those default values for RHF, which is a minimal version of the schema!
If I modify my required field map function to accept those:
export function zGetRequiredFieldMap(schema: ZodTypeAny, defaultValues: FieldValues): Record<string, true> { const result = schema.safeParse(defaultValues); … }
And the test:
const result = zGetRequiredFieldMap(mySchema, getDefaultValues());
Everything is in the green:
This enables me to generate my required field mapping from the schema and move ahead with Zod as a single source of truth.
Edge Cases & Limitations
While this solution works well for many use cases, conditional logic still requires some manual effort.
As I implemented this, I encountered some additional edge cases, particularly when a field switched from optional to required on the form based on the conditionals. In these cases, the required field mapping had to be regenerated or manually updated. This is very use case specific, and the general base principle still applies.
I found that, in general, Zod is geared more for runtime validation rather than model definition. It worked as a solution for the required indicator, but may not scale for other properties. For more complex situations, a more robust schema definition that includes the Zod schema as a property may be necessary.
Key Takeaways:
- Zod + RHF + TypeScript is a powerful combo for building reliable forms.
- Zod can serve as a single source of truth for form field requirements.
- Extracting required fields dynamically helps prevent validation drift.
- Default values are still needed and should be managed carefully.
- For complex conditional logic, more advanced schema strategies may be required.
More From Rachel Walker
About Keyhole Software
Expert team of software developer consultants solving complex software challenges for U.S. clients.
Perfect timing! Just had to rewrite our custom Yup <-> React Hook Form logic with zod and this saved me hours.
This has been the topic of discussion on our team for some time, definitely a worthy solution we’ll be looking into!