TypeScript

My z.coerce.number & RHF TypeScript Nightmare (Solved)

Struggling with Zod's z.coerce.number and React Hook Form in TypeScript? This guide solves the frustrating type errors and validation issues for good.

A

Alex Carter

Full-stack developer specializing in TypeScript, React, and building type-safe applications.

6 min read15 views

I've been there. You're building a sleek new form with React. You've wisely chosen React Hook Form (RHF) for performance and ergonomics, and you're pairing it with Zod for robust, type-safe validation. It feels like the modern React developer's dream stack. Everything's going smoothly... until you add an input for a number.

Suddenly, your console is a sea of red. TypeScript is screaming at you with cryptic messages. Your form, which seemed so perfect moments ago, is now a source of frustration. If this sounds familiar, you've likely fallen into the same trap I did: the subtle but maddening conflict between Zod's z.coerce.number, React Hook Form's type inference, and the fundamental nature of HTML inputs.

But don't worry. This isn't a bug in Zod or RHF. It's a classic case of type mismatch, and once you understand what's happening, the solution is beautifully simple. In this post, we'll dissect the problem and fix it for good.

The Scene of the Crime: A Simple Form Gone Wrong

Let's set up a typical scenario. We want a form to register a user, and we need to capture their age. Naturally, age should be a number.

Here’s what our initial, seemingly correct setup looks like.

Step 1: The Zod Schema

We define our schema. We want the age to be a number, and since HTML input values are strings, we reach for z.coerce.number. It's designed for exactly this—turning a string into a number before validation.

// src/schema.ts
import { z } from 'zod';

export const userSchema = z.object({
  name: z.string().min(1, 'Name is required'),
  age: z.coerce.number().min(18, 'You must be at least 18'),
});

export type UserFormData = z.infer<typeof userSchema>;
// Inferred type for 'age' is 'number'

Step 2: The React Hook Form Component

Next, we build our form component, connecting our schema using the @hookform/resolvers/zod package.

// src/components/UserForm.tsx
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { userSchema, UserFormData } from '../schema';

export const UserForm = () => {
  const { 
    register, 
    handleSubmit, 
    formState: { errors } 
  } = useForm<UserFormData>({
    resolver: zodResolver(userSchema),
  });

  const onSubmit = (data: UserFormData) => {
    console.log('Form data:', data);
    // data.age is a number here, thanks to Zod's coercion!
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <label htmlFor="name">Name</label>
        <input id="name" {...register('name')} />
        {errors.name && <p>{errors.name.message}</p>}
      </div>

      <div>
        <label htmlFor="age">Age</label>
        <input id="age" type="number" {...register('age')} />
        {/* ^^^ THE PROBLEM IS HERE ^^^ */}
        {errors.age && <p>{errors.age.message}</p>}
      </div>

      <button type="submit">Submit</button>
    </form>
  );
};

Step 3: The TypeScript Nightmare

As soon as you write {...register('age')}, TypeScript's language server lights up your editor with an error that looks something like this:

Advertisement
Type 'UseFormRegister<{ name: string; age: number; }>' is not assignable to type 'string'.
Argument of type '"age"' is not assignable to parameter of type 'Path<{ name: string; age: number; }>'.

...or a variation depending on your library versions. The core of the error is a type mismatch. What's going on?

Understanding the Core Conflict

The problem lies in a timing and responsibility mismatch between three different systems:

  1. HTML Inputs: An <input /> element's value property is always a string, even for type="number".
  2. TypeScript & RHF: When you provide UserFormData to useForm, TypeScript enforces that the fields managed by RHF match the inferred types. Your age field is typed as number based on the Zod schema. So, register('age') expects to work with a field that holds a number.
  3. Zod Coercion: z.coerce.number() does its magic during the validation process. This happens when you submit the form or when RHF triggers validation on blur/change.

See the disconnect? TypeScript is checking types statically, before any code runs. It sees that you're trying to connect a number-typed field from RHF to an HTML input that inherently produces a string. The coercion by Zod happens too late in the process to satisfy TypeScript's static analysis. Your code is trying to fit a square peg (string from the DOM) into a round hole (number type in your form state).

The Solution: `valueAsNumber` to the Rescue

For a long time, my go-to fix was messy. I'd create custom onChange handlers to manually parse the string, or I'd try complex Zod .preprocess() functions. These work, but they add unnecessary boilerplate.

The real solution is built right into React Hook Form. It's a simple option you can pass to the register function: valueAsNumber.

Let's fix our input field:

// ... inside your UserForm component

<input 
  id="age" 
  type="number" 
  {...register('age', { valueAsNumber: true })} 
/>

And just like that, the TypeScript error vanishes. The form validates correctly. The nightmare is over.

Why This Works So Well

By adding { valueAsNumber: true }, you are telling React Hook Form: "Hey, for this specific input, before you do anything else with its value, please try to parse it as a number."

This resolves the core conflict perfectly:

  • RHF now reads the value from the input and immediately converts it. An empty input becomes NaN, and a string like "25" becomes the number 25.
  • The value stored in the RHF state is now a number (or NaN).
  • This aligns perfectly with the number type that TypeScript expects from your UserFormData.
  • When Zod's validation runs, it's already receiving a number, which z.coerce.number() (or a simple z.number()) can validate without issue. NaN will correctly fail validation against z.number().

This approach is superior because it's declarative, idiomatic to React Hook Form, and requires minimal code. It solves the problem at the source—the moment the value is read from the DOM.

The Final, Working Code

Here is the complete, corrected UserForm component for clarity. Notice how clean and simple it is.

// src/components/UserForm.tsx (Corrected)
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { userSchema, UserFormData } from '../schema';

export const UserForm = () => {
  const { 
    register, 
    handleSubmit, 
    formState: { errors } 
  } = useForm<UserFormData>({
    resolver: zodResolver(userSchema),
  });

  const onSubmit = (data: UserFormData) => {
    console.log('Success!', data);
    alert(`User: ${data.name}, Age: ${data.age}`);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <label htmlFor="name">Name</label>
        <input id="name" {...register('name')} />
        {errors.name && <p style={{color: 'red'}}>{errors.name.message}</p>}
      </div>

      <div>
        <label htmlFor="age">Age</label>
        <input 
          id="age" 
          type="number" 
          {...register('age', { valueAsNumber: true })} 
        />
        {errors.age && <p style={{color: 'red'}}>{errors.age.message}</p>}
      </div>

      <button type="submit">Submit</button>
    </form>
  );
};

Conclusion: Keep Your Types Aligned

The combination of React Hook Form, Zod, and TypeScript provides an incredible developer experience for building robust forms. The z.coerce.number issue isn't a flaw in any of these tools but rather a fundamental impedance mismatch between the DOM and a strict type system.

The key takeaway is this: Ensure the data type RHF manages in its state matches what your Zod schema infers.

For numeric inputs, the most direct and idiomatic way to achieve this is by using the { valueAsNumber: true } option in RHF's register method. It's a simple fix that bridges the gap, silences TypeScript, and lets you get back to building great, type-safe applications. No more nightmares, just clean code.

Tags

You May Also Like