z.coerce.number() defaults empty strings to 0 #2814
-
When working with react-hook-form and using controlled inputs, we need to default values to empty strings or else there will be errors of going from uncontrolled to controlled. So using z.number() does not work because every input returns a string even if the input type is set number, it still returns a string in the event. Switching to |
Beta Was this translation helpful? Give feedback.
Replies: 18 comments 6 replies
-
Zod just uses the What do you want |
Beta Was this translation helpful? Give feedback.
-
Thanks for the reply @colinhacks, I was able to get around this by creating an actual But at the time, I was hoping for it to just return the empty string as opposed to Zod, because I dont know if the zero came from the user or from Zod. So the validation of having a minimum 0 to X would always pass even though there was no value inside of the field and the user never touched the field. |
Beta Was this translation helpful? Give feedback.
-
If I may add some color to this: Zod is built to represent the TypeScript type system at runtime. I doesn't know anything about React or HTML Forms or anything else, it's main job is to parse some value of type The fact that HTML Form controls, in general, are very loosely/stringily typed is kind of a well-known footgun and the source for a lot of what people make fun of JavaScript about 😂 . Having said that, I think @colinhacks 's question gets to the heart of the matter: Zod maps some input domain to a well-defined output domain, so if the output domain is In my view, form libraries should be doing the heavy lifting for you here: setting "empty" values to |
Beta Was this translation helpful? Give feedback.
-
@scotttrinh Totally understand. It's been a pretty well known problem and massive disconnect when dealing with types and values coming back from input fields. When setting fields to null/undefined and then add a value (if its controlled) then you get the error that a field has been changed from uncontrolled to controlled. so the empty string was the only logical thing to add as the default value and to also keep the input field empty (0 wouldn't work here because the field should be empty). so thats why we're here lol. but i totally understand your point and how Zod was developed. I just have to be more explicit about when dealing with the types of data i define in the schema and what components will be used for that type. This issue can be closed since this isn't an issue for Zod to fix anyway. For anyone curious what i did, I just created a |
Beta Was this translation helpful? Give feedback.
-
We had a similar issue and settled on |
Beta Was this translation helpful? Give feedback.
-
Several UI libraries use My current workaround to the problem is to extend the import {
ParseInput,
ParseReturnType,
ProcessedCreateParams,
RawCreateParams,
ZodErrorMap,
ZodFirstPartyTypeKind,
ZodNumber,
} from "zod";
// Directly copied from zod/src/types.ts
function processCreateParams(params: RawCreateParams): ProcessedCreateParams {
if (!params) return {};
const { errorMap, invalid_type_error, required_error, description } = params;
if (errorMap && (invalid_type_error || required_error)) {
throw new Error(
`Can't use "invalid_type_error" or "required_error" in conjunction with custom error map.`
);
}
if (errorMap) return { errorMap: errorMap, description };
const customMap: ZodErrorMap = (iss, ctx) => {
if (iss.code !== "invalid_type") return { message: ctx.defaultError };
if (typeof ctx.data === "undefined") {
return { message: required_error ?? ctx.defaultError };
}
return { message: invalid_type_error ?? ctx.defaultError };
};
return { errorMap: customMap, description };
}
export class CustomZodNumber extends ZodNumber {
_parse(input: ParseInput): ParseReturnType<number> {
// Alot of input ui libraries will send empty strings instead of undefined
// This is a workaround for not triggering invalid_type_error and rather required_error if not optional
input.data = input.data === "" ? undefined : input.data;
return super._parse(input);
}
static create = (
params?: RawCreateParams & { coerce?: boolean }
): CustomZodNumber => {
return new CustomZodNumber({
checks: [],
typeName: ZodFirstPartyTypeKind.ZodNumber,
coerce: params?.coerce || false,
...processCreateParams(params),
});
};
}
export const number = CustomZodNumber.create; Would love if |
Beta Was this translation helpful? Give feedback.
-
Is there a way to validate that the is not an empty string before coercing? z.number().or(z.string().nonempty())
.pipe(z.coerce.number())
// Or only in the context of form inputs
z.string().nonempty()
.pipe(z.coerce.number()) And for optional fields z.literal("").transform(() => undefined)
.or(z.coerce.number())
.optional() |
Beta Was this translation helpful? Give feedback.
-
@jacknevitt That would work but |
Beta Was this translation helpful? Give feedback.
-
Oh yeah, of course. I had forgotten about that one. Thanks! |
Beta Was this translation helpful? Give feedback.
-
For anyone interested, I found this solution (but without using const ZodStringNumberOrNull = z
.string()
.transform((value) => (value === '' ? null : value))
.nullable()
.refine((value) => value === null || !isNaN(Number(value)), {
message: 'Invalid number',
})
.transform((value) => (value === null ? null : Number(value))); This returns
|
Beta Was this translation helpful? Give feedback.
-
Using @maximeburri approach, I made a function that wraps the import { z, ZodTypeAny } from 'zod';
export const zodInputStringPipe = (zodPipe: ZodTypeAny) =>
z
.string()
.transform((value) => (value === '' ? null : value))
.nullable()
.refine((value) => value === null || !isNaN(Number(value)), {
message: 'Nombre Invalide',
})
.transform((value) => (value === null ? 0 : Number(value)))
.pipe(zodPipe); In use : const schema = zodInputStringPipe(z.number().positive('Le nombre doit être supérieur à 0')); May be handier to use in your schemas |
Beta Was this translation helpful? Give feedback.
-
Thank you, that's exactly what I was looking for. |
Beta Was this translation helpful? Give feedback.
-
@JacobWeisenburger Yes its been addressed, thank you! |
Beta Was this translation helpful? Give feedback.
-
The following example shows how you can use Zod with React Hook form with the default values being empty strings. In the example: const FormSchema = z
.object({
dogs: z.union([
z.coerce
.number({
message: "must be a number",
})
.int({
message: "must be a whole number",
})
.positive({
message: "must be positive",
}),
z.literal("").refine(() => false, {
message: "is required",
}),
]),
cats: z
.union([
z.coerce
.number({
message: "must be a number",
})
.int({
message: "must be a whole number",
})
.positive({
message: "must be positive",
}),
z.literal("").refine(() => false, {
message: "is required",
}),
])
.optional(),
})
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
defaultValues: {
dogs: "",
cats: "",
},
}); |
Beta Was this translation helpful? Give feedback.
-
this works and keeps the field typed as
|
Beta Was this translation helpful? Give feedback.
-
I wanted something similar but it should support a nullable number input
|
Beta Was this translation helpful? Give feedback.
-
Although I found the marked answer and others helpful, I had different requirements. Mine is a
And here's my solution totalPercentage: z.union([
z.coerce
.string()
.min(1, { message: "Total Percentage is required." })
.transform((v) => Number(v)),
z.literal("").refine(() => false, { message: "Total Percentage is required." }),
]) You could also make this as a reusable function. const zodNumType = (message: string) =>
z.union([
z.coerce
.string()
.min(1, { message })
.transform((v) => Number(v)),
z.literal("").refine(() => false, { message }),
]) Then, use the above function like this z.object({
totalPercentage: zodNumType("Total Percentage is required."),
monthlyPercentage: zodNumType("Monthly Percentage is required."),
})
|
Beta Was this translation helpful? Give feedback.
-
I think I found a much cleaner and simpler approach to treat empty number strings as null, throw errors, and still handle all numeric validation natively. Use Requiredconst validator = z.object({
numberInput: z
.preprocess(
(value) => (value === '' ? null : Number(value)),
z.number({ message: 'A number is required' })
.positive({ message: 'Number must be positive' })
)
}); Optionalnote that empty strings now return const validator = z.object({
numberInput: z
.preprocess(
(value) => (value === '' ? undefined : Number(value)),
z.number({ message: 'A number is required' })
.positive({ message: 'Number must be positive' })
.optional()
)
}); |
Beta Was this translation helpful? Give feedback.
Thank you, that's exactly what I was looking for.