Numbers
Beyond strings
We just covered string validation in depth. But not everything is text. Ratings, quantities, prices, ages, page numbers: these are all numbers, and they need their own constraints.
z.number() validates that a value is a JavaScript number. Let’s start with the basics:
import { z } from "zod/v4";
z.number().parse(42); // returns 42
z.number().parse(3.14); // returns 3.14
z.number().parse("42"); // throws — "42" is a string
z.number().parse(NaN); // throws — NaN is not a valid number Two things to notice here. First, "42" fails. Just like with booleans, Zod is strict about types. A string that looks like a number is still a string. Second, NaN also fails. This might seem surprising because typeof NaN === "number" in JavaScript, but NaN literally means “not a number.” It is the result of broken math operations like parseInt("abc"). Storing NaN in a database or using it in calculations produces nonsense, so Zod rejects it.
Number refinements
Raw number validation is almost never enough. You need to be specific about what numbers are acceptable. Just like with strings, you add refinements to narrow what values are accepted:
z.number().int(); // must be an integer (no decimals)
z.number().positive(); // must be > 0
z.number().nonnegative(); // must be >= 0
z.number().negative(); // must be < 0
z.number().min(1); // must be >= 1
z.number().max(100); // must be <= 100
z.number().finite(); // must not be Infinity or -Infinity Let’s try some:
z.number().int().parse(42); // passes
z.number().int().parse(3.14); // fails: "Expected integer, received float"
z.number().positive().parse(5); // passes
z.number().positive().parse(0); // fails: "Number must be greater than 0"
z.number().positive().parse(-1); // fails: "Number must be greater than 0"
z.number().min(1).max(5).parse(3); // passes
z.number().min(1).max(5).parse(6); // fails: "Number must be less than or equal to 5" These constraints chain just like string validators. You combine them to express exactly what you need.
Common patterns
Here are some patterns you will use again and again:
Ratings (1-5):
const RatingSchema = z.number().int().min(1).max(5);
RatingSchema.parse(4); // passes
RatingSchema.parse(0); // fails: must be >= 1
RatingSchema.parse(3.5); // fails: must be integer A rating must be a whole number between 1 and 5. No decimals, no zeros, no sixes.
Positive integers (IDs, counts, quantities):
const QuantitySchema = z.number().int().positive();
QuantitySchema.parse(1); // passes
QuantitySchema.parse(0); // fails: must be > 0
QuantitySchema.parse(-1); // fails: must be > 0 Prices (positive, up to a maximum):
const PriceSchema = z.number().positive().max(999999.99); Notice that prices allow decimals. If you needed exactly two decimal places, you would use a refinement (covered in a later lesson).
The string-to-number problem
Here is a problem you will run into immediately when working with HTTP. Query parameters and path parameters are always strings: ?page=2 gives you the string "2", not the number 2. And if you pass "2" to z.number(), it fails:
z.number().parse("2"); // fails: "Expected number, received string" You could convert manually with parseInt:
const page = parseInt(queryParam, 10);
z.number().int().min(1).parse(page); But Zod has a cleaner solution built in: z.coerce.number(). It calls Number(value) first, then validates the result:
z.coerce.number().parse("2"); // returns 2
z.coerce.number().parse("3.14"); // returns 3.14
z.coerce.number().parse("abc"); // fails: NaN is rejected This is a quick preview. We will cover z.coerce properly in the Preprocessing and Coercion lesson. For now, just know it exists for when you need to turn strings into numbers.
Applying to the contact form
Our contact form does not have number fields, but the pattern shows up elsewhere. If we were building a review system alongside it, the schema would look like this:
const ReviewSchema = z.object({
rating: z.number().int().min(1).max(5),
body: z.string().trim().min(1).max(5000),
}); And when we add pagination to the contact list endpoint later, page numbers and limits will be numbers that arrive as strings from query parameters.
Next up: booleans, dates, literals, and enums. These are the remaining building blocks you need before we start combining everything into object schemas.
Exercises
Exercise 1: Create a rating schema (integer, 1-5). Test with 3, 0, 6, 3.5. Verify the errors.
Exercise 2: Create a price schema (positive, max 999999.99). Test with 29.99, 0, -5, 1000000.
Exercise 3: Parse the string “42” with z.number() (fails) then with z.coerce.number() (succeeds). Compare.
Why does z.number() reject NaN even though typeof NaN === 'number'?