zod icon indicating copy to clipboard operation
zod copied to clipboard

How can I allow `null` as input value of a schema, but not as a valid output?

Open Svish opened this issue 2 years ago • 11 comments

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.

Svish avatar Jun 14 '22 12:06 Svish

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 avatar Jun 14 '22 16:06 scotttrinh

@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

Svish avatar Jun 15 '22 09:06 Svish

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.

Svish avatar Jun 15 '22 10:06 Svish

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.

scotttrinh avatar Jun 15 '22 16:06 scotttrinh

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"?

Svish avatar Jun 16 '22 06:06 Svish

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 🤔

Svish avatar Jun 16 '22 07:06 Svish

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.

scotttrinh avatar Jun 16 '22 14:06 scotttrinh

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.

scotttrinh avatar Jun 16 '22 14:06 scotttrinh

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?

Svish avatar Jun 17 '22 08:06 Svish

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?

alavkx avatar Jul 28 '22 20:07 alavkx

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.

stale[bot] avatar Sep 26 '22 22:09 stale[bot]

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?

kharann avatar Nov 24 '22 18:11 kharann

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.

divmgl avatar Dec 16 '22 06:12 divmgl

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)
  });

zoltanr-jt avatar Dec 16 '22 18:12 zoltanr-jt

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 avatar May 15 '23 10:05 lukasvice

@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.

Svish avatar May 15 '23 10:05 Svish

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 avatar May 15 '23 18:05 TonyGravagno

@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.

Svish avatar May 16 '23 06:05 Svish

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.

lukasvice avatar Jun 19 '23 12:06 lukasvice

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

lukasvice avatar Jun 22 '23 07:06 lukasvice