Lab notes · N°04 · · 3 min read
Fixing zodResolver type errors after upgrading to zod 4 in React Hook Form
Upgrading to zod 4 turns every zodResolver() red in React Hook Form. Three fixes: resolvers v5, dropping explicit useForm generics, and the z.coerce input trap.
- Written by
- Kamol Nazarov
- Published
- Tags
- zod · react · typescript
- Follow
- RSS feed
The note
The intake form for shop drop-offs runs on React Hook Form with a zod schema, so when zod 4 shipped I bumped the version in one line and let the agent upgrade the rest of the app. It came back with every zodResolver( call in the codebase underlined red, and TypeScript errors like this one:
TS2345: Argument of type '{ resolver: Resolver<FormValues, any, FormValues>; }'
is not assignable to parameter of type 'UseFormProps<FormValues, any>'.
Types of property 'resolver' are incompatible.
Type 'Resolver<{ title: string; slots: number }, any, { title: string; slots: number }>'
is not assignable to type 'Resolver<FormValues, any, FormValues>'.
If you searched "zodResolver type error zod 4" and landed here, the good news is the react hook form zod 4 type mismatch almost always comes down to three causes, and you can check them in order. No generic RHF+zod tutorial below � just the version pain.
| Your error mentions | The actual cause | Section |
|---|---|---|
Resolver<...> not assignable, or parse missing |
@hookform/resolvers too old for zod 4's types |
Fix 1 |
useForm<FormValues> + generic complaints |
Explicit generics fighting the resolver's inference | Fix 2 |
Input unknown / string where you wanted number |
z.coerce making input ? output |
Fix 3 |
Fix 1: put the versions in the only rows that line up
Zod 4 rewrote its TypeScript surface, and the resolver package was re-typed to match. @hookform/resolvers v3/v4 were built against zod 3 � against zod 4 they produce exactly this family of hookform resolvers zod 4 errors, even when runtime behavior looks fine on small schemas.
| zod | @hookform/resolvers | react-hook-form |
|---|---|---|
| 3.x | 3.x � 4.x | 7.x |
| 4.x | 5.x+ | 7.55+ |
npm i @hookform/resolvers@^5 react-hook-form@^7.55
npm ls zod @hookform/resolvers react-hook-form
Read the npm ls output, don't skim it: a second copy of zod 3 hiding under some other dependency keeps its old types in scope and re-creates the mismatch after you've "fixed" everything. If you see two zod entries, that dedupe is the fix, not your code.
Fix 2: drop the explicit useForm generics
The pattern everyone learned on zod 3:
type FormValues = z.infer<typeof schema>;
const form = useForm<FormValues>({ // ? the explicit generic is now the problem
resolver: zodResolver(schema),
});
zodResolver(schema) already knows the exact field values it will produce, and resolvers v5 + recent RHF infer TFieldValues straight from the resolver. When you pass your own generic on top, TypeScript tries to reconcile the two � the classic zodResolver useForm generic error � and any microscopic difference (an optional made undefined-able, a default added) turns into a wall of arrows. Let the schema drive:
const schema = z.object({
title: z.string().min(1),
slots: z.number().int().min(1),
});
const form = useForm({
resolver: zodResolver(schema), // field types inferred from the schema
});
type FormValues = z.infer<typeof schema>; // for handlers, still fine
Deleting code as the fix feels like a trick, but the explicit generic was duplicating information the resolver now owns. This one change clears the majority of the red after the version bump.
Fix 3: the z.coerce trap � input ? output
HTML forms hand you strings. Coercion was the standard answer:
const schema = z.object({
slots: z.coerce.number().int().min(1),
});
Here's the trap: z.infer returns the output type (number), but React Hook Form registers and reads input values (what the DOM actually holds � string/unknown for coerced fields). Type the form as the output and the resolver's input side no longer fits:
TS2322: Type 'unknown' is not assignable to type 'number'.
For any schema where input and output differ (coerce, preprocess, transforms), use all three generics useForm accepts � input, context, output:
const form = useForm<z.input<typeof schema>, any, z.output<typeof schema>>({
resolver: zodResolver(schema),
});
Alternatively, side-step coercion at the field level with register's built-in: {...form.register('slots', { valueAsNumber: true })} and keep the schema on plain z.number(). Both are legitimate; the three-generic form is the one that keeps validation and typing in one place, valueAsNumber is the one that keeps the schema honest about what a text input contains.
The 30-second checklist
npm ls zod @hookform/resolvers react-hook-form� one zod, resolvers 5+, RHF 7.55+.- Remove explicit
useForm<...>generics; letzodResolverinfer. - Still red? Check for
z.coerce/preprocessand switch to the three-genericuseFormform.
One sentence to keep: zod 4 didn't break your form � it made the resolver the source of truth, so match its version and let it do the typing. The rest of the zod 4 migration (the z.email() rename, one-argument z.record) is mechanical; this wiring is the only part that fights back.