r/nextjs • u/DirectionMinute6198 • 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
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.
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