3.4 KiB
title, impact, impactDescription, tags
| title | impact | impactDescription | tags |
|---|---|---|---|
| Pass the Third useForm Generic When the Resolver Transforms Values | CRITICAL | makes handleSubmit receive the schema's output type instead of its input type | formcfg, generics, resolver, zod, transform, typescript |
Pass the Third useForm Generic When the Resolver Transforms Values
useForm takes three generics: useForm<TFieldValues, TContext, TTransformedValues>. The first is what lives in the form (what inputs produce, before validation); the third is what handleSubmit hands your success callback. They are the same type only when the schema does no transformation.
The moment a schema uses z.coerce, .transform(), or a .default(), input and output diverge — the form holds a string, the schema yields a Date or a number. Omit the third generic and TypeScript pins the output to the input type, which the resolver then contradicts. With @hookform/resolvers v5 the error lands on the resolver: property and reads like this:
Type 'Resolver<{ arrivesOn: string; … }, any, { arrivesOn: Date; … }>' is not assignable to
type 'Resolver<{ arrivesOn: string; … }, any, { arrivesOn: string; … }>'.
Types of property 'arrivesOn' are incompatible.
Type 'Date' is not assignable to type 'string'.
Nothing in that message mentions a missing generic, so the usual reactions are to cast the resolver, widen the schema until the transform is gone, or drop the resolver's type entirely — all of which trade a correct error for silently wrong types. This is the most common typing failure in RHF + Zod and the fix is one type argument.
Incorrect (one generic — the resolver's output type contradicts the form's, and handleSubmit is typed with the pre-validation input):
const bookingSchema = z.object({
guests: z.coerce.number().int().min(1),
arrivesOn: z.iso.date().transform((value) => new Date(value)),
})
type BookingInput = z.input<typeof bookingSchema>
function BookingForm() {
const { register, handleSubmit } = useForm<BookingInput>({
resolver: zodResolver(bookingSchema),
defaultValues: { guests: '1', arrivesOn: '' },
})
// The resolver above fails to typecheck; values.arrivesOn is string, but is a Date at runtime
return <form onSubmit={handleSubmit((values) => createBooking(values))} />
}
Correct (three generics — the callback is typed with the schema's output):
const bookingSchema = z.object({
guests: z.coerce.number().int().min(1),
arrivesOn: z.iso.date().transform((value) => new Date(value)),
})
type BookingInput = z.input<typeof bookingSchema>
type BookingOutput = z.output<typeof bookingSchema>
function BookingForm() {
const { register, handleSubmit } = useForm<BookingInput, unknown, BookingOutput>({
resolver: zodResolver(bookingSchema),
defaultValues: { guests: '1', arrivesOn: '' },
})
// values.arrivesOn is Date, values.guests is number — matching runtime
return <form onSubmit={handleSubmit((values) => createBooking(values))} />
}
The middle generic is the resolver context; pass unknown when you don't use one. Derive both types from the schema (z.input / z.output) rather than hand-writing them, so they can't drift.
If handleSubmit is fighting you about a field type, check this generic before reaching for as.
Reference: useForm · React Hook Form Resolvers