type-safe.dev

Table of contents

    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 inputs 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>;
    }