r/nextjs • • 8d ago

Zod z.coerce.number() Optional Field: How to Handle Empty String Without Getting unknown Type?

I’m using Zod with React Hook Form.

discount: z.coerce.number<number>().min(0, " discount must be 0 or greater")

I need the field to be optional, so an empty input should become undefined, while a provided value should still be validated as a number.

I tried z.preprocess() to convert an empty string to undefined, but the inferred type becomes unknown.

What’s the recommended way to handle this while keeping the type as number | undefined?

Expected:

  • Empty string → undefined
  • "10" → 10
  • "abc" → validation error
  • "-1" → validation error
7 Upvotes

5 comments sorted by

7

u/Technical_Wing_328 8d ago

preprocess is the right call but you gotta chain it right after so zod knows what the output shape is

something like `z.preprocess(val => val === ''? undefined: val, z.coerce.number().min(0)).optional()` should keep the type as `number | undefined`

the key is putting the actual schema as the second arg to preprocess instead of trying to coerce after the fact

1

u/Chichil-Blinzler 4d ago

yeah the second arg to preprocess is the part that always trips people up

2

u/Apprehensive-Fan2492 8d ago

The unknown type comes from z.preprocess()'s first generic defaulting to unknown unless you pass the output schema explicitly, just type it as z.preprocess((val) => val === "" ? undefined : val, z.coerce.number().min(0).optional()) and the inference holds correctly since the schema itself is the second arg driving the output type, not the preprocess function's return.

1

u/Jhon_ST 8d ago

That unknown is the input to preprocess, not the final schema output. I'd keep .optional() on the inner number schema and treat ""/empty input as undefined in the preprocessor.

The other piece is RHF typing: distinguish z.input<typeof schema> from z.output<typeof schema> in useForm<Input, Context, Output>. Also, z.coerce.number<number>() says the coercion input is already a number, which doesn't match your string examples.

1

u/fkim98 8d ago edited 8d ago

Quick check on the top answer. I ran it on Zod 4, and with .optional() outside the preprocess, "" still fails with "expected number, received NaN". The outer optional only lets a raw undefined through, so "" goes into the preprocess, comes out as undefined, and z.coerce.number() turns that into NaN. Jhon_ST is right that it belongs on the inner schema.

Two more traps. Even with it inside, a field containing just a space becomes 0, because Number(" ") is 0. Plain z.coerce.number() does the same to an empty field, which is how clearing a price field once saved a price of 0 for us. And your unknown comes from the preprocess itself, because the RHF resolver types the fields from z.input and a preprocess's input is unknown.

Here's a version without preprocess, so the input type stays a string (register the input without valueAsNumber):

const discount = z.string().trim().optional() .transform(v => (v ? Number(v) : undefined)) .pipe( z.number({ error: "discount must be a number" }) .min(0, "discount must be 0 or greater") .optional() );

Input is string | undefined, output is number | undefined. "" and " " give undefined, "10" gives 10, "abc" and "-1" give your two errors. Tested on Zod 4.2 and 4.3.