React

Using z.coerce.number with RHF & ZodResolver in 2024

Tired of number input validation errors with React Hook Form and Zod? Learn how z.coerce.number provides an elegant, modern solution to this common problem.

A

Alex Garcia

A senior frontend developer passionate about clean code, TypeScript, and great user experiences.

6 min read21 views

If you’ve been building forms in React lately, you’re probably familiar with the dream team: React Hook Form (RHF) for performance and developer experience, and Zod for robust, type-safe validation. It’s a powerful combination that lets you build complex forms with confidence. But there’s one persistent, nagging issue that trips up nearly every developer at some point: handling number inputs.

You set up your beautiful Zod schema with z.number(), you wire up your input field, and you type in “25”. The validation fails. You check your code, pull your hair out, and wonder what could possibly be wrong. The answer is deceptively simple: HTML form inputs, even <input type="number">, always return their value as a string. Zod, being strict, sees the string "25" and correctly says, “Nope, that’s not a number.”

For years, developers have wrestled with this, using manual parsing, `onChange` handlers, or RHF’s `valueAsNumber` prop. But in 2024, there’s a much cleaner, more elegant solution built right into Zod: z.coerce.number(). This post is your definitive guide to understanding and mastering this feature to keep your form logic clean, simple, and all in one place.

The Core Problem: A Tale of Two Types

Let's visualize the problem with a minimal example. Imagine you're building a simple product form where you need to capture a price.

Your Zod schema looks perfectly reasonable:

import { z } from 'zod';

const ProductSchema = z.object({
  name: z.string().min(1, "Name is required"),
  price: z.number().positive("Price must be a positive number"),
});

And your React component uses a standard number input:

// Inside your form component...
<div>
  <label htmlFor="price">Price</label>
  <input id="price" type="number" {...register('price')} />
  {errors.price && <p>{errors.price.message}</p>}
</div>

When the user types “50” into the input field, React Hook Form receives the value "50" (a string). It passes this string to the Zod resolver. Zod then checks "50" against the price: z.number() rule. The validation fails with an error like “Expected number, received string.” This is technically correct but incredibly frustrating from a user and developer perspective.

The Old Ways: Workarounds and Their Downsides

Before coercion became mainstream in Zod, developers came up with several workarounds. While they work, they each have drawbacks that complicate your code.

Method How it Works Downside
Manual onChange Parsing Use a controlled component or RHF's Controller to intercept the onChange event and call setValue('price', parseInt(e.target.value)). Adds significant boilerplate and moves transformation logic out of the schema and into the component.
RHF's valueAsNumber Register the input with {...register('price', { valueAsNumber: true })}. RHF attempts to convert the value to a number. Can be inconsistent. An empty input becomes NaN, which can cause validation issues. The conversion happens outside Zod's context.
Zod's z.preprocess z.preprocess((val) => Number(val), z.number()). This is the direct predecessor to coerce. Verbose and less readable than the modern alternative. It's functionally similar but syntactically clunky.

These methods scatter your form logic. Your validation rules are in the schema, but your data transformation is happening somewhere else. This violates the principle of a single source of truth.

Advertisement

The Modern Solution: Introducing z.coerce.number()

Zod introduced coercion to solve this exact problem. The word “coerce” means to persuade or force something to happen. In this context, z.coerce.number() tells Zod: “Before you validate this value as a number, try to force it into one.”

It works by running the value through the global Number() constructor before any other validation checks. This means "50" becomes 50, "" becomes 0, and "hello" becomes NaN.

The beauty is in the implementation. You just swap z.number() with z.coerce.number(). That's it.

Our schema from before is instantly fixed:

import { z } from 'zod';

const ProductSchema = z.object({
  name: z.string().min(1, "Name is required"),
  // The only change is here!
  price: z.coerce.number().positive("Price must be a positive number"),
});

Now, your entire validation and transformation pipeline is declared in one place: the schema. It’s declarative, easy to read, and keeps your components clean.

Putting It All Together: A Practical RHF Example

Let's build a complete, copy-paste-ready example to see it in action. First, make sure you have the necessary packages installed:

npm install react-hook-form zod @hookform/resolvers

Next, create your form component.

import React from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

// 1. Define the Zod schema with coercion
const ProductFormSchema = z.object({
  productName: z.string().min(3, { message: 'Product name must be at least 3 characters.' }),
  quantity: z.coerce
    .number({ invalid_type_error: 'Quantity must be a number.' })
    .min(1, { message: 'You must order at least 1 item.' })
    .positive(),
});

// Infer the TypeScript type from the schema
type ProductFormData = z.infer<typeof ProductFormSchema>;

export const ProductForm = () => {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<ProductFormData>({
    // 2. Connect Zod to React Hook Form
    resolver: zodResolver(ProductFormSchema),
  });

  const onSubmit = (data: ProductFormData) => {
    // 3. Data is fully typed and validated!
    console.log(data);
    // Example output: { productName: 'My Awesome Product', quantity: 10 }
    // Notice `quantity` is a number, not a string!
    alert(`Success! Data: ${JSON.stringify(data, null, 2)}`);
  };

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

      <div>
        <label htmlFor="quantity">Quantity</label>
        {/* 4. The input is standard, no extra props needed */}
        <input id="quantity" type="number" {...register('quantity')} />
        {errors.quantity && <p style={{ color: 'red' }}>{errors.quantity.message}</p>}
      </div>

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

This example is clean, robust, and easy to maintain. The component is only concerned with rendering the UI and handling the final submission. All the complex logic of transformation and validation is perfectly encapsulated within ProductFormSchema.

Handling Edge Cases Like a Pro

Coercion is powerful, but it's important to understand how it behaves in edge cases.

Empty Inputs

An empty input field (value="") is coerced by Number(""), which results in 0. If your field is required and must be greater than zero, this can be an issue. For example, if a user just clicks submit without touching the quantity field, it will be 0.

Solution: Chain a .min(1) validation. Our schema already does this:

z.coerce.number().min(1, "You must order at least 1 item.")

Now, if the user submits an empty field, it will be coerced to 0, which then fails the .min(1) check, showing the correct error message. This is a simple and effective pattern for required positive numbers.

Non-Numeric Text

What if a user manages to input non-numeric text like "abc"? (This is possible even with type="number" in some browsers or via copy-paste).

Number("abc") results in NaN (Not a Number). Zod's internal number check will then fail, as NaN is not a valid number. By default, you'll get an error like “Expected number, received nan”. This isn't very user-friendly.

Solution: Provide a custom error message for type errors.

z.coerce.number({ invalid_type_error: "Please enter a valid quantity." })

Now, if the coercion results in NaN, the user will see your helpful, custom message instead of a cryptic default one.

Conclusion: Embrace Coercion for Cleaner Forms

The friction between string-based HTML inputs and number-based validation schemas has long been a source of minor but persistent pain for web developers. With z.coerce.number(), Zod provides a definitive, first-class solution that aligns perfectly with the goal of a single source of truth for your form logic.

By moving data transformation into the schema itself, you simplify your components, reduce boilerplate, and create a more robust and maintainable validation system. The next time you build a form with React Hook Form and Zod, don't reach for old workarounds. Start with z.coerce and enjoy the clean, seamless developer experience you deserve in 2024.

Tags

You May Also Like