zod
zod copied to clipboard
How can I allow `null` as input value of a schema, but not as a valid output?
I'm trying to use zod
and react-hook-form
together, and find it a bit difficult to deal setting defaultValues
in a way that makes both react-hook-form
, typescript
and the actual schema validation happy.
Say you have this schema:
const zodSchema = z.number().int().nonnegative()
type ZodValues = z.input<typeof zodSchema>
// number <-- want null to be allowed here
type ZodValid = z.output<typeof zodSchema>
// number
Here both ZodValues
and ZodValid
does not allow null
as a value.
If I add nullable
, we get this:
const zodSchema = z.number().int().nonnegative().nullable()
type ZodValues = z.input<typeof zodSchema>
// number | null
type ZodValid = z.output<typeof zodSchema>
// number | null // <-- don't want null to be allowed here
Using [email protected]
(latest now), it seems I'm able to do it like this, which is what I want:
const yupSchema = number().nullable(true).required().integer().min(0)
type YupValues = TypeOf<typeof yupSchema>
// number | null | undefined
type YupValid = Asserts<typeof yupSchema>
// number
Is there any way I can write this schema with zod
, so that the input allows null
, while the output does not?
The issue is that react-hook-form
preferably wants non-undefined default values for the input, and for e.g. number
and Date
inputs I'd really prefer to use null
as I do not want to pick a random number or date to use as the default.
You can achieve this with a transform, but that means that you're going to have to "pick a random number or date to use as the default" in the case where you pass null
into the input. The difference between yup and zod is that zod considers itself a parser, so if you say a schema takes number | null
and returns number
you need to map everything from the input domain to the output domain, which requires that you pick a number for the null
case.
I don't have a lot of experience with react-hook-form
, but I suspect they don't really require the defaultValues
to have the same type as the input type of the validation schema, right? In that case, is there a parsing/validation need for taking inputs of type T | null
?
@scotttrinh That makes sense, but does transform
happen before or after validation? I can for example transform into NaN
if the value is null
, but would the validation happen "on" null
or NaN
in that case?
Like, the following seems work type-wise, but not sure I understand what exactly happens with the validation in this case:
const zodSchema= z.number()
.positive()
.nullable()
.transform((value) => value ?? NaN)
type ZodValues = z.input<typeof zodSchema>;
// number | null
type ZodValid = z.output<typeof zodSchema>;
// number
As for the react-hook-form
part of it, I asked a question in their repo for that. Basically, their useForm
only accepts a single generic for their TFieldValues
, which is used for both defaultValues
and the handleSubmit
, meaning you have to pick one. I suppose I could just go with z.input
for both, and then just trigger an extra parse
before I pass it to the request-handler, or something like that, but yeah. nothing really zod
related.
but does transform happen before or after validation?
It happens after input parsing, if that makes sense. The flow goes:
flowchart LR
A[input] --> B{Nullable?};
B -- null --> D[Transform];
B -- number --> C{Positive?};
B -- other --> F[Throw];
C -- true --> D[Transform];
C -- false --> F[Throw];
D -- null --> G[NaN];
D -- number --> H[number];
meaning you have to pick one. I suppose I could just go with z.input for both, and then just trigger an extra parse before I pass it to the request-handler, or something like that, but yeah. nothing really zod related.
I don't think you need to trigger an extra parse, I believe the integration already passes it through the parser. You might need to add some type annotations in our submit handler or something like that, but I think you can/should trust the output of react-hook-form
to have been run through the schema already. Let me know if you find this isn't the case and we can work with them to get it working properly.
Reading your flow diagram, it then seems that null
would actually get through all of validation, without being stopped anywhere, and finally be transformed to NaN
, which would then be considered "valid"?
Yeah, just confirmed it. .nullable()
allows null
to get through, which I guess I should've expected. But then I'm even less sure how to actually allow null
type-wise, but not validation-wise 🤔
Yeah, I think maybe that's the crux of the issue: the two types describes valid inputs and valid outputs. It doesn't describe the domain of expected inputs that might be sent (that is unknown
really).
For my purposes, I don't care that much about the type of the form state since forms have a very loose set of data structures compared to my much more restricted domain model. Whatever works is fine and I trust that the schema does the right thing in all of the situations such that I can "trust" the parsed output. Does that make sense? I don't know how that squares with the various form libraries, though.
allows null to get through
Yeah, and that's why I said you'll have to map null
to something in the number
domain since you're saying the input domain is number | null
and the output is number
. You have two choices: null
is not a valid input, then you have number
to number
; null
maps to a number
like NaN
or 0
or -1
or whatever makes sense for your application.
Discovered the transform
function gets a ctx
with an addIssue
function, so this seems to be a workaround of sorts...
z
.positive()
.nullable()
.transform((value, ctx): number => {
if (value == null)
ctx.addIssue({
code: 'custom',
message: 'X Cannot be null',
});
return value ?? NaN;
})
That gets correct type, and stops it with a validation issue. Will be quite annoying to have to add that to every nullable number though, haha.
Does zod
have any way to "extend" it with custom functions? Like, is it possible to add custom stuff to the "chain", like a .nullNotAllowed()
or creditCardNumber()
or something like that?
I already commented in another issue, but it seems more appropriate here. I'm terribly confused how to deal with defaultValue
when using zod
with react-hook-form
. https://github.com/colinhacks/zod/issues/804#issuecomment-1198591366
FWIW I'm encountering similar struggles when attempting to work with number inputs, using zod
as a react-hook-form
resolver. It is.....pretty challenging to figure out.
https://codesandbox.io/s/stupefied-moser-0fpq94?file=/src/App.tsx
Given...
- a strongly typed endpoint
- a matching zod schema (consider tRPC)
- a form design for HTTP PATCH; a partial update
- the need to represent NO CHANGE as an empty input
- HTML's native behavior to represent EMPTY as empty string ('')
How do you represent a number input?
This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.
I'm feeling some problems with this issue too when dealing with databases.
Values from databases are usually null. Ideally, I would parse them with zod and transform all nulls into undefined. Null has some weird behavior and therefore I would rather use undefined if I can. Does anyone know if it is possible to transform all nulls on outputs, but allow outputs on inputs?
I'm facing this issue too when building forms. The value is correctly null
because the type exists and is present in the object, but the value has not yet been picked.
Hey @Svish! This helped me: https://github.com/react-hook-form/react-hook-form/issues/8046#issuecomment-1073348421
I used the DefaultValues
generic type to create
import {DefaultValues} from "react-hook-form";
export type FormType = z.infer<typeof FormSchema>
export const initialFormData: DefaultValues<FormType> = {
name: undefined,
nested: {
amount: 1,
date1: undefined,
date2: undefined,
time: undefined
}
};
then
const methods = useForm<FormType>({
defaultValues: initialFormData,
resolver: zodResolver(FormSchema)
});
Setting the default values works with the DefaultValues
type, as described in @zoltanr-jt's answer. But what about
const date = methods.watch('nested.date1')
This always returns the form field type set by Zod (date
). But this is not true, because it can also be undefined
if no value has been set yet.
@lukasvice Yep, I have the same issue. But, I think it needs to be fixed in react-hook-form
, or at least in @hookform/resolvers
, since it's not really a problem with zod
. zod
actually has the tools (z.input
and z.output
), but the react-hook-form
types don't use the correct ones in correct context. z.output
should only be used as the type for the submit-handler, while z.input
should be used for defaultValues
, values
and things like watch
, setValue
, etc.
I've been challenged with this same issue. Documented the question in Discussions before I saw this Issue:
https://github.com/colinhacks/zod/discussions/2431
Code here: https://gist.github.com/TonyGravagno/2b744ceb99e415c4b53e8b35b309c29c
🚨 I'm hoping this ticket gets re-opened because it's not a closed topic.
Ref https://github.com/colinhacks/zod/discussions/1953 where we're collaborating on code to generate a valid Default object from a Zod schema. Is this exactly what's done in React Hook Form?
While I am using react-hook-form, in code that is not using that great library I would still like to use Zod, so I'd prefer not to have to rely on the external library to generate defaults for this one.
At this moment I'm struggling with the simple concept where:
birthDate: z.coerce.date(undefined)
defaultData.birthDate
is null
and .safeParse(defaultData)
validates true
because it converts null into the date string for 1967/12/31.
I don't want the default. I'm intentionally setting the date as null or undefined because I want safeParse to fail until a valid value is set.
@TonyGravagno RHF simply has a DefsultValues
type helper, which recursively converts a given type into partials (making everything optional).
Re your date issue, that's an issue with your own code. If you don't want null
coerced into a date, then don't use coerce. Coerce simple passes whatever value it gets through new Date
. If that gives you something wrong, don't use it.
You can now use the TTransformedValues
parameter of useForm
:
const schema = z.object({
age: z
.number()
.positive()
.nullable()
.transform((value, ctx): number => {
if (value == null) {
ctx.addIssue({
code: "invalid_type",
expected: "number",
received: "null"
});
return z.NEVER;
}
return value;
})
});
type SchemaIn = z.input<typeof schema>;
type SchemaOut = z.output<typeof schema>;
const form = useForm<SchemaIn, never, SchemaOut>({
resolver: zodResolver(schema),
defaultValues: {
age: null
}
});
This also works with watch
🥳
Check out https://codesandbox.io/s/rhf-zod-defaultvalue-vxmncm?file=/src/App.tsx
However, it would be really great to have a better solution for the "transform" / "addIssue" thing.
Update: I ended up doing something like this:
const schema = z.object({
age: z.number().positive()
});
type SchemaOut = z.input<typeof schema>;
type SchemaIn = Omit<SchemaOut, 'age'> & {
age: SchemaOut[age] | null
}
Or use a WithNullableFields
helper like this one: https://stackoverflow.com/a/72241609