The z.coerce.number & RHF TypeScript Error: A Fix
Struggling with a TypeScript error using z.coerce.number and React Hook Form? Learn the root cause and discover the simple, elegant fix using `setValueAs`.
Elena Petrova
A senior frontend engineer specializing in TypeScript, React, and building type-safe applications.
The z.coerce.number & RHF TypeScript Error: A Fix
You’ve crafted the perfect tech stack: React, TypeScript, React Hook Form for slick forms, and Zod for bulletproof validation. It’s a developer’s dream. You’re building a form, and you need a number input—maybe for a quantity, an age, or a price. Easy enough. You reach for Zod’s handy z.coerce.number
, wire it up with your RHF Controller
, and... bam. Your terminal erupts in a sea of red, screaming about types that just don’t seem to get along.
If you’ve seen an error like Type 'string' is not assignable to type 'number'
, you’re in the right place. This isn’t a bug in your code or a flaw in the libraries; it’s a classic case of a type mismatch hiding in plain sight. Let’s unravel this common frustration and implement a clean, idiomatic fix.
The Scene of the Crime: Recreating the Error
First, let's look at the code that typically causes this headache. You probably have something that looks a lot like this:
You define a Zod schema to shape your form data. You want the quantity
to be a number, but you know HTML inputs give you strings, so z.coerce.number
seems like the perfect tool.
// schema.ts
import { z } from 'zod';
export const productSchema = z.object({
productName: z.string().min(1, 'Product name is required'),
quantity: z.coerce.number().min(1, 'Quantity must be at least 1'),
});
export type ProductFormData = z.infer;
Then, in your component, you set up React Hook Form using the Zod resolver and a Controller
to connect your input field.
// ProductForm.tsx
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { productSchema, ProductFormData } from './schema';
const ProductForm = () => {
const { control, handleSubmit } = useForm({
resolver: zodResolver(productSchema),
defaultValues: {
productName: '',
quantity: 1, // Notice this is a number
},
});
// ... form submission handler
return (
);
};
When you do this, TypeScript rightfully complains. Hovering over the {...field}
spread on the <input>
element will likely show you an error message along these lines:
Type '{ onChange: (...args: any[]) => void; onBlur: Noop; value: number; name: "quantity"; ref: RefCallBack; }' is not assignable to type 'DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>'.
Type 'number' is not assignable to type 'string | number | readonly string[] | undefined'.
Understanding the Root Cause: String vs. Number
So what’s actually happening? The conflict arises from a fundamental truth of web development:
HTML input elements, unless specified otherwise, always deal with strings.
Let's trace the data flow to see the mismatch:
- Zod's Perspective: Your schema, via
z.infer
, tells TypeScript thatProductFormData['quantity']
must be anumber
. - React Hook Form's State: RHF honors this. The form state for
quantity
is expected to be anumber
. Thefield.value
passed by theController
is, therefore, anumber
. - HTML's Reality: The standard HTML
<input>
element'svalue
attribute expects astring
. When you spread{...field}
, you are trying to assign a numericfield.value
to an attribute that wants a string. TypeScript spots this and flags it. - The Coercion Timing:
z.coerce.number
is a validation-time transformation. It only runs when RHF triggers validation (on change, on blur, or on submit). It doesn’t affect the value as it’s being passed from the input to the RHF state in real-time.
The core issue is a timing mismatch: RHF gives the input a number, but the input gives RHF back a string, and TypeScript is caught in the middle.
The Elegant Fix: Meet setValueAs
Thankfully, the React Hook Form team anticipated this exact scenario. Buried within the register
and Controller
options is a powerful little helper: setValueAs
.
setValueAs
is a function that intercepts the value from the input before it's saved into the form state. It allows you to transform the raw input value into the format your schema and form state expect. This is the perfect place to convert our input's string into a number.
Implementing the Fix
Let's modify our Controller
to use this feature. It's a one-line change.
// ProductForm.tsx (The Fixed Version)
{
// Return an empty string if the value is not a number
// This allows the user to clear the input
return isNaN(parseInt(value)) ? "" : parseInt(value);
},
}}
render={({ field }) => (
field.onChange(e.target.value)}
type="text"
placeholder="Quantity"
/>
)}
/>
Wait, that looks a bit more complex. There's a simpler way. The issue often comes from how we handle the value for the input itself. Let's try a cleaner approach.
The most common error is when the value
from the field is a number, but the input wants a string. We can just convert it back to a string for the input, and use setValueAs
to convert it to a number for the form state.
A Cleaner Implementation
Let's refine the approach. The goal is to ensure the input always receives a string, and the form state always receives a number.
// ProductForm.tsx (The BEST Version)
(
{
const value = e.target.value;
onChange(value === '' ? 0 : Number(value));
}}
/>
)}
/>
While the above works, it overrides the `onChange` logic. The most idiomatic RHF solution is still `setValueAs`, but it's often misunderstood. The key is to transform the `field.value` for the input display, while letting `setValueAs` handle the state update.
The Definitive Pattern for RHF + Zod Numbers
Let's combine the best of both worlds. We'll let the controller know how to parse the value, and we'll ensure the input gets a string.
// The truly robust and simple solution
(v === "" ? undefined : Number(v)) }}
render={({ field }) => (
field.onChange(e.target.value)}
/>
)}
/>
Why does this work so well?
setValueAs: (v) => (v === "" ? undefined : Number(v))
: When the user types, the raw string value (`v`) is captured. If they clear the input, we set the form state to `undefined`. Otherwise, we convert it to aNumber
. This happens before the value hits the form state and Zod.value={field.value === undefined ? "" : String(field.value)}
: This handles the other direction. It takes the numeric value from RHF's state (field.value
) and ensures it's always a string before being passed to the `<input>`'s `value` prop. If the value is `undefined` (like on initial render or after clearing), we show an empty string.onChange={(e) => field.onChange(e.target.value)}
: This is standard. It passes the raw string value from the input to RHF, which then pipes it through our `setValueAs` function.
With this pattern, TypeScript is happy, RHF is happy, and Zod gets the number it expects during validation. All types are aligned at every step of the process.
Wrapping It Up
The z.coerce.number
and React Hook Form TypeScript error is a rite of passage for many developers. It’s a perfect example of how types can clash between the browser's DOM, your application's state, and your validation logic.
While it might seem complex at first, the solution is beautifully simple once you understand the flow of data. By using RHF's built-in setValueAs
rule to handle the incoming string and manually ensuring the input receives a string back, you create a robust, type-safe, and user-friendly number input.
So next time you see that dreaded type error, don't panic. Just remember to bridge the gap between HTML's strings and your schema's numbers. Happy coding!