If you're using Zod with react-hook-form, you've probably seen this at least once:
Invalid input: expected number, received NaN
At first glance, it looks like a simple validation issue.
It’s not.
The real problem
When working with form inputs:
- All values come in as strings
-
z.coerce.number()tries to convert them - Empty input (
"") or invalid values →NaN
And here’s the catch:
Zod fails before your .min() / .max() validations run.
So instead of your custom message, you get a generic (and not very helpful) error.
It gets trickier
If you're using TypeScript:
-
z.input<typeof schema>≠z.output<typeof schema> -
react-hook-formworks with the input type - Zod gives you the output type after parsing
This mismatch can lead to confusing type errors and wrong assumptions about your data.
The fix
You need to handle invalid input before Zod tries to validate it:
readTime: z.preprocess((val) => {
if (val === "" || val === undefined) return undefined;
const num = Number(val);
return isNaN(num) ? undefined : num;
},
z.number()
.min(2, 'Minimum read time is 2 minutes')
.max(60, 'Maximum read time is 60 minutes')
)
What this solves
- Empty input → handled properly
- Invalid numbers → no more NaN issues
- Custom validation messages → actually shown
- Cleaner UX overall
Takeaway
If you're using z.coerce.number() in forms, don't rely on it blindly.
Always normalize your input first.
Because sometimes the bug isn't in your validation rules…
it's in how your data arrives before validation even starts.
Top comments (0)