React 75 ⚛️ Schema Validation with Zod Integration
Zod is the schema validation library that has become the default companion to React Hook Form. Its role is to define the shape of your form data once—as a TypeScript-first schema—and let both the form and the rest of your application share that definition. The integration point is zodResolver from @hookform/resolvers, which translates Zod’s validation results into the errors object that React Hook Form exposes . The result is a form where the validation rules, the TypeScript types, and the error messages all originate from a single schema file, rather than being scattered across register calls and duplicated in server-side checks.
The reason this pattern is worth adopting is consistency. A Zod schema can be imported by the client form, a server action, an API route, or a test, and all of them validate against the same rules . The z.infer type ensures that the onSubmit handler receives fully typed data—data.email is a string, data.age is a number—without manual interface declarations. The trade-off is a small amount of setup ceremony and a set of known integration pitfalls around optional fields, coercion, and type inference that this chapter covers.
Key point: The Zod integration pattern is: define a schema with z.object(), infer the TypeScript type with z.infer, pass zodResolver(schema) to useForm, and let Zod’s error messages flow into formState.errors; the schema is the single source of truth for validation and types.
Why Zod integration exists
The duplicated rules problem. Without a schema, validation rules live in two places: the register calls on the client and the validation logic on the server. They drift. The client allows a password of six characters, the server requires eight, and the user only discovers the mismatch after a round trip. A shared schema eliminates the drift because both sides import the same definition .
The type inference problem. Inline register rules give you runtime validation but not compile-time types. The onSubmit handler receives any, and a typo like data.emial compiles without error. A Zod schema with z.infer produces a concrete type, so the handler is typed, autocomplete works, and the compiler catches field name mistakes .
The error mapping problem. Zod’s safeParse returns an error object with an issues array, where each issue has a path (the field it belongs to) and a message. React Hook Form’s zodResolver performs this mapping automatically: errors.email.message is populated from the Zod issue whose path is ['email'] . Without the resolver, you would write the mapping yourself.
The transformation problem. Form inputs are strings. A number field returns "25", not 25. Zod’s z.coerce.number() converts the string to a number before validating it, so the schema can express numeric constraints like .min(18) without the form having to parse values manually . This is a capability that inline register rules do not have.
The composition problem. A schema can be extended, merged, picked, or omitted to create variants. baseSchema.pick({ email: true, password: true }) creates a login schema from a registration schema. baseSchema.merge(profileSchema) combines two schemas. This is how large applications avoid duplicating field definitions across related forms .
a. The setup and core integration
The integration requires three packages: zod, react-hook-form, and @hookform/resolvers . The schema is defined with z.object(), and the type is inferred with z.infer.
import { z } from 'zod'
export const loginSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(8, 'Password must be at least 8 characters')
})
export type LoginData = z.infer<typeof loginSchema>
The useForm hook receives the resolver and the inferred type. The register calls have no validation rules—the schema handles all validation.
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
function LoginForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting }
} = useForm<LoginData>({
resolver: zodResolver(loginSchema),
defaultValues: { email: '', password: '' }
})
const onSubmit = (data: LoginData) => {
// data.email is string, data.password is string
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} />
{errors.email && <span>{errors.email.message}</span>}
<input {...register('password')} type="password" />
{errors.password && <span>{errors.password.message}</span>}
<button disabled={isSubmitting}>Submit</button>
</form>
)
}
The errors object mirrors the schema’s shape. For nested fields like address.city, the error is at errors.address.city.message. The defaultValues are still required—the schema does not provide them, and without them the controlled/uncontrolled warning appears .
The validation mode is configured on useForm, not the schema. The default is onSubmit, which is the least noisy. For forms where users expect immediate feedback, onBlur validates when a field loses focus .
b. Optional fields, coercion, and refinement
Optional fields are where the most common integration bug appears. Zod’s .optional() means the field can be undefined. An HTML input that is left empty produces "", not undefined. Zod validates "" against the string schema (which may have .min(1)), and the optional check never applies . The fixes are to use z.string().optional().or(z.literal("")), or to preprocess the empty string to undefined, or to set the input’s default value to undefined and handle the controlled warning carefully .
website: z.string().url().optional().or(z.literal(""))
Coercion solves the string-to-number problem. z.coerce.number() applies Number() before validation, so "25" becomes 25 and .min(18) works as expected . This is the correct pattern for age, quantity, price, and any numeric field that comes from an <input>.
age: z.coerce.number().min(18, 'Must be 18 or older')
Refinement handles cross-field validation. A password confirmation field must match the password. The refine method attaches the check to the object, and the path option routes the error to the correct field.
const schema = z.object({
password: z.string().min(8),
confirmPassword: z.string()
}).refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword']
})
Without the path option, the error appears at the form level (errors.root) rather than next to the confirmation field, which is confusing for users .
c. Type inference and version compatibility
Zod v4 introduced changes to type inference that interact with @hookform/resolvers. The known issue is that TypeScript may fail to infer the resolver’s generic type correctly, producing errors like “Type instantiation is excessively deep” or missing properties on the errors object . The workarounds are to explicitly type useForm with z.infer<typeof schema>, to ensure that @hookform/resolvers is updated to a version that supports Zod v4, or to import from zod/v3 if the resolver has not yet caught up .
const form = useForm<z.infer<typeof schema>>({
resolver: zodResolver(schema)
})
The explicit generic is a defensive measure. Even when inference works, it makes the intended type visible and catches mismatches between the schema and the form configuration.
For large schemas with discriminated unions or deeply nested objects, the TypeScript compiler may hit recursion limits. Splitting the form into smaller sub-forms with separate schemas is the pragmatic fix . A 300-field form with a resolver and formState reads can freeze for several seconds during registration; the mitigation is to read formState properties inline rather than destructuring the whole object .
Complete Example Session
The following session builds a profile settings form with optional fields, coercion, and a cross-field refinement.
// ============================================
// PART 1: SCHEMA DEFINITION
// ============================================
import { z } from 'zod'
const profileSchema = z.object({
username: z.string().min(3, 'Username must be at least 3 characters'),
age: z.coerce.number().min(18, 'Must be 18 or older'),
website: z.string().url('Invalid URL').optional().or(z.literal('')),
password: z.string().min(8, 'Password must be at least 8 characters'),
confirmPassword: z.string()
}).refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword']
})
type ProfileData = z.infer<typeof profileSchema>
// ============================================
// PART 2: FORM COMPONENT
// ============================================
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
function ProfileForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting }
} = useForm<ProfileData>({
resolver: zodResolver(profileSchema),
defaultValues: {
username: '',
age: undefined,
website: '',
password: '',
confirmPassword: ''
},
mode: 'onBlur'
})
// ============================================
// PART 3: SUBMIT HANDLER
// ============================================
const onSubmit = async (data: ProfileData) => {
// data.age is number, website may be undefined or string
await fetch('/api/profile', {
method: 'POST',
body: JSON.stringify(data)
})
}
// ============================================
// PART 4: FORM MARKUP
// ============================================
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label>Username</label>
<input {...register('username')} />
{errors.username && <span>{errors.username.message}</span>}
</div>
<div>
<label>Age</label>
<input type="number" {...register('age')} />
{errors.age && <span>{errors.age.message}</span>}
</div>
<div>
<label>Website (optional)</label>
<input {...register('website')} />
{errors.website && <span>{errors.website.message}</span>}
</div>
<div>
<label>Password</label>
<input type="password" {...register('password')} />
{errors.password && <span>{errors.password.message}</span>}
</div>
<div>
<label>Confirm Password</label>
<input type="password" {...register('confirmPassword')} />
{errors.confirmPassword && <span>{errors.confirmPassword.message}</span>}
</div>
<button type="submit" disabled={isSubmitting}>Save</button>
</form>
)
}
The schema uses z.coerce.number() for age, .optional().or(z.literal('')) for the website, and .refine() with path for the password confirmation. The form uses mode: 'onBlur' to validate when fields lose focus. The defaultValues include undefined for age and empty strings for the text fields. The errors object provides field-specific messages from the Zod schema.
Quick Reference
Packages
| Package | Purpose |
|---|---|
zod | Schema definition and validation |
@hookform/resolvers | Provides zodResolver |
Integration Pattern
| Step | Code |
|---|---|
| Schema | z.object({ email: z.string().email() }) |
| Type | type Data = z.infer<typeof schema> |
| Form hook | useForm<Data>({ resolver: zodResolver(schema) }) |
| Errors | errors.email?.message |
Common Zod Patterns
| Pattern | Use Case |
|---|---|
z.coerce.number() | Convert input string to number |
.optional().or(z.literal("")) | Allow empty string for optional field |
.refine(fn, { path: ['field'] }) | Cross-field validation with error routing |
.transform(fn) | Transform value after validation |
Known Issues
| Issue | Workaround |
|---|---|
| Zod v4 type inference errors | Explicit useForm<z.infer<...>> or import from 'zod/v3' |
| Optional field treated as required | Empty string is not undefined; use .or(z.literal("")) |
| Refinement error at form root | Add path to refine options |
| Large form freeze | Split into sub-forms, read formState inline |
Best Practices
✅ Do This:
const schema = z.object({ age: z.coerce.number().min(18) }) // ✅ coerce numeric inputs
website: z.string().url().optional().or(z.literal("")) // ✅ optional + empty string
.refine(fn, { path: ['confirm'] }) // ✅ route cross-field errors
useForm<z.infer<typeof schema>>({ resolver: zodResolver(schema) }) // ✅ explicit type
❌ Don’t Do This:
age: z.number().min(18) // ❌ input is string, fails
website: z.string().optional() // ❌ "" fails optional check
.refine(fn) // ❌ error at root, not field
useForm({ resolver: zodResolver(schema) }) // ❌ inference may fail
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Optional field shows required error | Empty string fails string validation | .optional().or(z.literal("")) |
| Number field fails validation | Input is string, schema expects number | z.coerce.number() |
| Password mismatch error at form root | refine missing path | Add path: ['confirmPassword'] |
| TypeScript error on resolver | Zod v4 inference incompatibility | Explicit generic, update resolver |
data.field is any | useForm not typed | useForm<z.infer<typeof schema>> |
| Large form freezes | 300+ fields with resolver and formState | Split into sub-forms |
Real-World Examples
1. Basic email schema
z.object({ email: z.string().email('Invalid email') })
2. Password confirmation
.refine((d) => d.password === d.confirm, { path: ['confirm'] })
3. Numeric coercion
z.coerce.number().min(18, 'Must be 18 or older')
4. Optional URL field
z.string().url().optional().or(z.literal(''))
5. Inferred type
type FormData = z.infer<typeof schema>
6. Resolver setup
useForm({ resolver: zodResolver(schema) })
7. Nested field error
errors.address?.city?.message
8. Schema composition
baseSchema.pick({ email: true, password: true })
9. Server-side validation
const parsed = schema.safeParse(data)
10. Transform after validation
z.string().transform((v) => v.trim())
Visual
Zod + RHF Data Flow
┌──────────────────────────────────────────────┐
│ Zod Schema │
│ z.object({ email: z.string().email() }) │
│ │ │
│ ▼ │
│ z.infer → TypeScript type │
│ │ │
│ ▼ │
│ useForm<Type>({ resolver: zodResolver }) │
│ │ │
│ ▼ │
│ User submits form │
│ │ │
│ ▼ │
│ zodResolver runs schema.safeParse │
│ │ │
│ ┌────┴────┐ │
│ │ │ │
│ valid invalid │
│ │ │ │
│ ▼ ▼ │
│ onSubmit errors populated │
│ receives { email: { message: ... } } │
│ typed data │
└──────────────────────────────────────────────┘
Optional Field Bug
┌──────────────────────────────────────────────┐
│ Schema: z.string().optional() │
│ │
│ Input empty → value is "" │
│ │ │
│ ▼ │
│ Zod checks: is "" a string? YES │
│ Is it optional? No, it's defined as "" │
│ │ │
│ ▼ │
│ Validation fails if string has .min(1) │
│ │
│ Fix: .optional().or(z.literal("")) │
│ Empty string explicitly allowed │
└──────────────────────────────────────────────┘
Coercion Flow
┌──────────────────────────────────────────────┐
│ Input value: "25" (string) │
│ │ │
│ ▼ │
│ z.coerce.number() │
│ applies Number("25") → 25 │
│ │ │
│ ▼ │
│ .min(18) validates 25 >= 18 → pass │
│ │ │
│ ▼ │
│ onSubmit receives data.age as number 25 │
└──────────────────────────────────────────────┘
Refinement Error Routing
┌──────────────────────────────────────────────┐
│ .refine((d) => d.a === d.b, { │
│ message: 'Mismatch', │
│ path: ['b'] ← routes error to field b │
│ }) │
│ │ │
│ ▼ │
│ errors.b.message = 'Mismatch' │
│ (not errors.root) │
└──────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Schema definition | z.object({ field: z.string() }) |
| Type inference | z.infer<typeof schema> |
| Resolver | zodResolver(schema) |
| Form hook | useForm<Type>({ resolver }) |
| Error access | errors.field?.message |
| Optional field fix | .optional().or(z.literal("")) |
| Numeric coercion | z.coerce.number() |
| Cross-field validation | .refine(fn, { path: ['field'] }) |
| Shared schema | Import in client and server |
| Known issue | Zod v4 inference with resolver |
Key takeaways:
- Zod is the single source of truth. The schema defines validation rules, error messages, and TypeScript types. The form, the server, and the tests all import the same definition .
zodResolvermaps Zod errors toformState.errors. The field name in the schema must match the field name inregister.- Optional fields fail on empty strings. An HTML input returns
"", notundefined. Use.optional().or(z.literal(""))to allow empty values . z.coerce.number()handles numeric inputs. Without it, a number field returns a string and.min()comparisons fail .- Refinement needs
pathfor field-level errors. Without it, cross-field errors appear at the form root . - Explicit typing avoids inference issues.
useForm<z.infer<typeof schema>>is safer than relying on automatic inference, especially with Zod v4 . - The schema is shared with the server.
schema.safeParse(data)on the server validates the same rules that the client form enforced .
Remember: Zod and React Hook Form are designed to work together, but the integration has sharp edges. Define the schema first, infer the type, and pass the resolver to useForm. Handle optional fields with the empty-string workaround, use coercion for numeric inputs, and route refinement errors to the correct field with path. The schema then serves three purposes: it validates the form on the client, it types the onSubmit handler, and it can be reused on the server to ensure that the data arriving at the API satisfies the same rules. That single definition is the reason to adopt the pattern—not the validation itself, which register rules can also provide, but the consistency across the entire data flow.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!