Type-Safe Input Coercion in React Hook Form + Zod
If you have ever implemented a form that submits data to a backend API, you have probably run into cases where the API expects non‑string fields. A common example is a numeric price field of an item onan E-commerce site. However, because HTML form elements like <input> always provide their value as a string (even when type="number" is set), you must convert the string value to a number before calling the submission API.
This article explains how to handle this input coercion in a type‑safe way in a React application that uses React Hook Form and Zod.
Code Example
Consider the following simple shop item form that uses React Hook Form for form state management and Zod for validation.
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const formSchema = z.object({
name: z.string(),
price: z.number(),
});
export default function Home() {
const { register, handleSubmit, watch } = useForm({
resolver: zodResolver(formSchema),
});
return (
<form
onSubmit={handleSubmit(data => {
// Call a submit API
})}
>
<div>
<label htmlFor="name">Name</label>
<input id="name" type="text" {...register("name")} />
</div>
<div>
<label htmlFor="price">Price</label>
<input id="price" type="number" {...register("price")} />
</div>
<button type="submit">Add Item</button>
</form>
);
}With the current implementation, the form will fail validation when the user submits it because of the Zod schema. The HTML <input> value is always string, but the Zod schema expects the price field to be a number.
Option 1: React Hook Form’s valueAsNumber
React Hook Form provides a valueAsNumber option on the register function. When enabled, React Hook Form automatically converts the input’s string value to a number.
To convert the price field to a number, update the registration as follows:
<input
id="price"
type="number"
- {...register("price")}
+ {...register("price", { valueAsNumber: true })}
/>With this change, the price form value is converted to a number and the Zod validation passes.
This conversion happens before validation, so it also affects React Hook Form APIs such as watch and getValues that read the current form values.
const { register, handleSubmit, watch } = useForm({
resolver: zodResolver(formSchema),
});
// Return type is `number`
watch("price");
handleSubmit(data => {
// Type is `number`
data.price;
})};Putting it all together, the updated example looks like this:
const formSchema = z.object({
name: z.string(),
price: z.number(),
});
export default function Home() {
const { register, handleSubmit, watch } = useForm({
resolver: zodResolver(formSchema),
});
return (
<form
onSubmit={handleSubmit(data => {
// Call a submit API
})}
>
<div>
<label htmlFor="name">Name</label>
<input id="name" type="text" {...register("name")} />
</div>
<div>
<label htmlFor="price">Price</label>
<input
id="price"
type="number"
{...register("price", { valueAsNumber: true })}
/>
</div>
<button type="submit">Add Item</button>
</form>
);
}Option 2: Zod’s Coercion
Instead of using React Hook Form’s valueAsNumber option, you can also use Zod’s Coercion API to achieve a similar result. Zod’s coercion schema converts input data into another type. For example, z.coerce.number().parse("123") returns the numeric value 123.
In the example, you can use z.coerce when defining formSchema:
const formSchema = z.object({
name: z.string(),
- price: z.number(),
+ price: z.coerce.number<string>(),
});With this change, Zod converts the price value to a number during validation. This behavior differs from Option 1, which converts the value before validation. As a result, the price value passed to the handleSubmit callback is a number, but React Hook Form APIs such as watch, which read values before validation, still return a string.
const { register, handleSubmit, watch } = useForm({
resolver: zodResolver(formSchema),
});
// Returns type is `string`
watch("price");
handleSubmit(data => {
// Type is `number`
data.price;
})};Also note that z.coerce.number is given an input type of string as a generic parameter. This is important because React Hook Form uses the Zod schema’s input type to infer the types of APIs like watch. Without specifying the input type for the coercion schema, the return type of watch would be unknown.
const formSchema = z.object({
name: z.string(),
// Without specifying the input type
price: z.coerce.number(),
});
// Return type is `unknown`
watch("price");const formSchema = z.object({
name: z.string(),
// With input type specified
price: z.coerce.number<string>(),
});
// Return type is `string`
watch("price");For more details, see Zod’s documentation for the Coerce API.
Putting it together, the example using Zod coercion looks like this:
const formSchema = z.object({
name: z.string(),
price: z.coerce.number<string>(),
});
export default function Home() {
const { register, handleSubmit, watch } = useForm({
resolver: zodResolver(formSchema),
});
return (
<form
onSubmit={handleSubmit(data => {
// Call a submit API
})}
>
<div>
<label htmlFor="name">Name</label>
<input id="name" type="text" {...register("name")} />
</div>
<div>
<label htmlFor="price">Price</label>
<input id="price" type="number" {...register("price")} />
</div>
<button type="submit">Add Item</button>
</form>
);
}Choosing Your Option
Both options are valid, and each has different trade‑offs.
- If you want the converted type to be reflected consistently across all React Hook Form APIs (such as
watchandgetValues), choose Option 1 (valueAsNumber). - If you prefer to keep type conversion in the Zod layer and keep your form UI code simpler, choose Option 2 (Zod coercion).