TypeScript

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`.

E

Elena Petrova

A senior frontend engineer specializing in TypeScript, React, and building type-safe applications.

6 min read14 views

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 (
    
{/* ... other fields ... */} ( // ^^^ The source of the TypeScript error is right here! )} /> ); };

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.

Advertisement

Let's trace the data flow to see the mismatch:

  1. Zod's Perspective: Your schema, via z.infer, tells TypeScript that ProductFormData['quantity'] must be a number.
  2. React Hook Form's State: RHF honors this. The form state for quantity is expected to be a number. The field.value passed by the Controller is, therefore, a number.
  3. HTML's Reality: The standard HTML <input> element's value attribute expects a string. When you spread {...field}, you are trying to assign a numeric field.value to an attribute that wants a string. TypeScript spots this and flags it.
  4. 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 a Number. 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!

Tags

You May Also Like