7th Jan, 2024

Optional number inputs with zod & react-hook-form

Say we have the following form using zod and react-hook-form:

const schema = z.object({
  duration: z.number().optional(),

export const Form = () => {

  const { handleSubmit, control, register } = useForm<FormData>({
    resolver: zodResolver(schema),
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("duration", { valueAsNumber: true })} />

Submitting the from will not work. We can inspect the errors to see what's wrong:

export const Form = () => {

  const { handleSubmit, control, register, formState: { errors } } = useForm<FormData>({
    resolver: zodResolver(schema),

  // ...

Which will yield: 'Expected number, received nan'. What happened?

Looking at the react-hook-form documentation, we learn that valueAsNumber will fall back to NaN if no conversion is possible1:

Returns a Number normally. If something goes wrong NaN will be returned.

Let's move over to zod: The documentation for optional() shows that the inferred type expects the defined type or undefined.

const user = z.object({
  username: z.string().optional(),
type C = z.infer<typeof user>; // { username?: string | undefined };

Remember that NaN and undefined are different values. So we know why the form is not validating: We're passing an unexpected value for duration.

How do we fix it? Luckily, react-hook-form also provides another option for register, setValueAs(). We can use that instead:

<input {...register("duration", 
    { setValueAs: (value) => (value === "" ? undefined : parseInt(value)) }

This way, we make sure we set the value to the expected undefined if the input is left empty.

moshyfawn suggested another approach to the problem on twitter using zod's coerce functionality, shifting the value transformation from react-hook-form to zod:

const schema = z.object({
  duration: z.coerce.number().min(0).optional(),


  1. This is a limitation react-hook-form has inherited from the native HTMLInputElement

© 2024 Chris Jarling