A better way to use Zod with RHF
The problem
If you've ever used react-hook-form
with zod
, then it is likely you have run into a weird impedance mismatch. Let's look at an example:
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
const MyFormModel = z.object({
foo: z.string(),
});
type MyForm = z.infer<typeof MyFormModel>;
function MyForm() {
const { register, handleSubmit } = useForm({
resolver: zodResolver(MyFormModel),
});
function onSubmit(data: MyForm) {
// ...
}
// @ts-expect-error
return <form onSubmit={handleSubmit(onSubmit)}></form>;
}
As is, this example doesn't type-check, even though one might expect it to. We get this error when passing our (correctly typed) onSubmit
handler to handleSubmit
:
Argument of type '(data: { foo: string; bar: number; }) => void' is not assignable to parameter of type 'SubmitHandler<FieldValues>'.
Types of parameters 'data' and 'data' are incompatible.
Type 'FieldValues' is missing the following properties from type '{ foo: string; bar: number; }': foo, bar
The problem here is that useForm
is not using our zodResolver(MyFormModel)
to infer the types for SubmitHandler
-- it just defaults to FieldValues
, which is just Record<string, any>
.
Solution: First attempt
The usual solution to this problem that I see is explicitly setting the type of useForm
:
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
const MyFormModel = z.object({
foo: z.string(),
});
type MyForm = z.infer<typeof MyFormModel>;
function MyForm() {
const { register, handleSubmit } = useForm<MyForm>({
resolver: zodResolver(MyFormModel),
});
function onSubmit(data: MyForm) {
// ...
}
return <form onSubmit={handleSubmit(onSubmit)}></form>;
}
This can be fine in some cases, but unfortunately, it's not enough for all cases. Forms are tricky and annoying at times, especially when dealing with number inputs or dates and the like.
One might, for example, be dealing with a model that looks something like this
const MyFormModel = z.object({
number: z.number(),
date: z.date(),
});
type MyForm = z.infer<typeof MyFormModel>;
Seems reasonable, right? Let's look at how such a form would work in practice
This form is set to display the current state of the form. Even with <input type="number"/>
, which there are good reasons to avoid, you will see that when you enter a number, the internal state is still a string. The same applies to dates; no serialization is performed by the input
fields regardless of their chosen type. If you try to submit, you will also see that we get errors: it expects a number or a date, but input
only hands us strings.
More problems
This is not even the whole story. Because we have fixed the type of our form to MyForm
, if we want to set defaultValues
, it will only accept data which matches the model.
This works,
const {
// ...
} = useForm<MyForm>({
defaultValues: {
number: 0,
date: new Date(),
},
resolver: zodResolver(MyFormModel),
});
.. but this does not.
const {
// ...
} = useForm<MyForm>({
defaultValues: {
number: "0",
date: "2023-01-01",
},
resolver: zodResolver(MyFormModel),
});
So what can we do here? One might consider changing the model so that all fields are strings
const MyFormModel = z.object({
number: z.string(),
date: z.string(),
});
And while this would give us the proper types for defaultValues
, we will have lost out on the biggest benefit of having the model in the first place: validation. Now our onSubmit
handler will only receive strings and we have no idea whether the values are valid numbers or dates.
Solution: Second attempt
The solution is to use coercion.
const MyFormModel = z.object({
number: z.string().pipe(z.coerce.number()),
date: z.string().pipe(z.coerce.date()),
});
type MyForm = z.infer<typeof MyFormModel>;
Unfortunately, this is still not quite good enough. We have our cake, but we can't eat it too. We run into a problem with defaultValues
- it expects us to give it a value that matches the end result of running our form state through our zod
model, but as we already established, HTML input
s only deal in strings. Ideally, we should be able to distinguish between the input type and the output type. Well, it turns out that is indeed possible.
If we take a look at the rather hefty type signature for useForm
..
export declare function useForm<
TFieldValues extends FieldValues = FieldValues,
TContext = any,
TTransformedValues extends FieldValues | undefined = undefined,
>(
props?: UseFormProps<TFieldValues, TContext>,
): UseFormReturn<TFieldValues, TContext, TTransformedValues>;
.. we see that there are actually more than one type parameters in play. We have TFieldValues
, TContext
and TTransformedValues
. Well, as the name might suggest, the last one, TTransformedValues
is actually the type that useForm
will use as the type that is passed to our onSubmit
handler, which means that TFieldValues
is the type that it expects for defaultValues
. How we arrive at this conclusion is mostly uninteresting -- just follow the types all the way -- so for the sake of brevity, I will leave that as an exercise for the reader.
Best of both worlds
There is still one piece of the puzzle missing, though. Armed with this knowledge, we can now type useForm
properly, but how can we set both type parameters correctly based on our model, when using sorcery such as coerce
? It turns out we are in luck: zod
has the concept of input
and output
types.
const MyFormModel = z.object({
number: z.string().pipe(z.coerce.number()),
date: z.string().pipe(z.coerce.date()),
});
type MyFormOutput = z.output<typeof MyFormModel>;
// { number: number; date: Date; }
type MyFormInput = z.input<typeof MyFormModel>;
// { number: string; date: string; }
We can now write our useForm
call like this:
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
const MyFormModel = z.object({
number: z.string().pipe(z.coerce.number()),
date: z.string().pipe(z.coerce.date()),
});
type MyFormOutput = z.output<typeof MyFormModel>;
type MyFormInput = z.input<typeof MyFormModel>;
function MyForm() {
const { register, handleSubmit } = useForm<
MyFormInput,
unknown,
MyFormOutput
>({
resolver: zodResolver(MyFormModel),
// no type error
defaultValues: {
number: "0",
date: "2023-01-01",
},
});
function onSubmit(data: MyFormOutput) {
// ...
}
// no type error
return <form onSubmit={handleSubmit(onSubmit)}></form>;
}
The only remaining downside here is that we are still explicitly specifying the types for useForm
. This should be unnecessary since we are inferring these types directly from our model. While react-hook-form
would ideally be capable of doing this on its own, it does not currently, but we can thankfully just write our own wrapper around useForm
to do this for us:
export function useZodForm<Schema extends z.ZodTypeAny, Context = unknown>(
props: Exclude<UseFormProps<z.input<Schema>, Context>, "resolver"> & {
schema: Schema;
},
): UseFormReturn<z.input<Schema>, Context, z.infer<Schema>> {
return useForm<z.input<Schema>, Context, z.infer<Schema>>({
...props,
resolver: zodResolver(props.schema),
});
}
We can use this as follows:
import { z } from "zod";
import { UseFormProps, UseFormReturn, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
export function useZodForm<Schema extends z.ZodTypeAny, Context = unknown>(
props: Exclude<UseFormProps<z.input<Schema>, Context>, "resolver"> & {
schema: Schema;
},
): UseFormReturn<z.input<Schema>, Context, z.infer<Schema>> {
return useForm<z.input<Schema>, Context, z.infer<Schema>>({
...props,
resolver: zodResolver(props.schema),
});
}
const MyFormModel = z.object({
number: z.string().pipe(z.coerce.number()),
date: z.string().pipe(z.coerce.date()),
});
type MyForm = z.infer<typeof MyFormModel>;
function MyForm() {
const { register, handleSubmit } = useZodForm({
schema: MyFormModel,
// no type error
defaultValues: {
number: "0",
date: "2023-01-01",
},
});
function onSubmit(data: MyForm) {
// ...
}
// no type error
return <form onSubmit={handleSubmit(onSubmit)}></form>;
}