Kamol Nazarov The back office lab Brooklyn, NY

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

  1. npm ls zod @hookform/resolvers react-hook-form � one zod, resolvers 5+, RHF 7.55+.
  2. Remove explicit useForm<...> generics; let zodResolver infer.
  3. Still red? Check for z.coerce/preprocess and switch to the three-generic useForm form.

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.