Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Closed
Svish opened this issue Jun 14, 2022 · 22 comments
Closed
Labels
stale No activity in last 60 days

Comments

@Svish
Copy link

Svish commented Jun 14, 2022

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 yup@0.32.11 (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.

@scotttrinh
Copy link
Collaborator

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?

@Svish
Copy link
Author

Svish commented Jun 15, 2022

@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
Copy link
Author

Svish commented Jun 15, 2022

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.

@scotttrinh
Copy link
Collaborator

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];
Loading

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.

@Svish
Copy link
Author

Svish commented Jun 16, 2022

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
Copy link
Author

Svish commented Jun 16, 2022

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 🤔

@scotttrinh
Copy link
Collaborator

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
Copy link
Collaborator

scotttrinh commented Jun 16, 2022

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.

@Svish
Copy link
Author

Svish commented Jun 17, 2022

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?

@alavkx
Copy link

alavkx commented Jul 28, 2022

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. #804 (comment)

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?

@stale
Copy link

stale bot commented Sep 26, 2022

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 stale bot added the stale No activity in last 60 days label Sep 26, 2022
@stale stale bot closed this as completed Oct 4, 2022
@kharann
Copy link

kharann commented Nov 24, 2022

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?

@divmgl
Copy link

divmgl commented Dec 16, 2022

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.

@zoltanr-jt
Copy link

Hey @Svish!
This helped me:
react-hook-form/react-hook-form#8046 (comment)

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

@lukasvice
Copy link

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.

@Svish
Copy link
Author

Svish commented May 15, 2023

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

@TonyGravagno
Copy link

TonyGravagno commented May 15, 2023

I've been challenged with this same issue. Documented the question in Discussions before I saw this Issue:
#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 #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.

@Svish
Copy link
Author

Svish commented May 16, 2023

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

@lukasvice
Copy link

lukasvice commented Jun 19, 2023

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
Copy link

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

@likerRr
Copy link

likerRr commented Jun 12, 2024

In case if someone still facing the issue. Zod has a dedicated paragraph on how to do type refinements, which in my case also works for react-hook-form. For example I have a form where the input value can be either File | null, but submit value must be strictly File. What I do:

const schema = z.object({
  file: z
    .instanceof(File) 
    .nullable()
    .superRefine((arg, ctx): arg is File => {
      if (!(arg instanceof File)) {
        ctx.addIssue({
          code: z.ZodIssueCode.custom,
          message: 'Select a file',
          fatal: true, // abort early on this error in order not to fall down to the next refine with a null value
        });
      }

      return z.NEVER;
    })
    .refine(
      // here typescript already knows that the "file" is a File
      // But if we don't do "fatal: true" in superRefinement, zod will also pass null, despite TS says it's a File
      file => file.size < 10 * 1024 * 1024,
      `Size limit exceeded`,
    )
})

type FormInitial = z.input<typeof schema>; // { file: File | null }
type FormSubmit = z.output<typeof schema>; // { file: File }

// then use it with react-hook-form
const form = useForm<FormInitial, any, FormSubmit>({
  resolver: zodResolver(schema),
  defaultValues: {
    file: null, // accepts File | null
  },
});

form.handleSubmit(data => /** data here is a FormSubmit type **/)
const file = form.watch('file'); // "file" is "File | null"

I saw the answer with using transform, but in my opinion the transform should be used for data transformation, e.g. changing it's type, shape or whatever. Refine is for validation purposes, which is the case when my schema accepts null value when I expect File in the output.

@likerRr
Copy link

likerRr commented Jun 12, 2024

In case if someone still facing the issue. Zod has a dedicated paragraph on how to do type refinements, which in my case also works for react-hook-form. For example I have a form where the input value can be either File | null, but submit value must be strictly File. What I do:

const schema = z.object({
  file: z
    .instanceof(File) 
    .nullable()
    .superRefine((arg, ctx): arg is File => {
      if (!(arg instanceof File)) {
        ctx.addIssue({
          code: z.ZodIssueCode.custom,
          message: 'Select a file',
          fatal: true, // abort early on this error in order not to fall down to the next refine with a null value
        });
      }

      return z.NEVER;
    })
    .refine(
      // here typescript already knows that the "file" is a File
      // But if we don't do "fatal: true" in superRefinement, zod will also pass null, despite TS says it's a File
      file => file.size < 10 * 1024 * 1024,
      `Size limit exceeded`,
    )
})

type FormInitial = z.input<typeof schema>; // { file: File | null }
type FormSubmit = z.output<typeof schema>; // { file: File }

// then use it with react-hook-form
const form = useForm<FormInitial, any, FormSubmit>({
  resolver: zodResolver(schema),
  defaultValues: {
    file: null, // accepts File | null
  },
});

form.handleSubmit(data => /** data here is a FormSubmit type **/)
const file = form.watch('file'); // "file" is "File | null"

I saw the answer with using transform, but in my opinion the transform should be used for data transformation, e.g. changing it's type, shape or whatever. Refine is for validation purposes, which is the case when my schema accepts null value when I expect File in the output.

I have to correct myself. Since my solution works fine, I ended up using transform instead of superRefine, cause transform infers resulting type and it's very handful. I also made a little snippet which can be easily used as a wrapper over any zod type:

const nullableInput = <T extends ZodTypeAny>(
  schema: T,
  message = 'Output value can not be null',
) => {
  return schema.nullable().transform((val, ctx) => {
    if (val === null) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        fatal: true,
        message,
      });

      return z.NEVER;
    }

    return val;
  });
};

// usage:
const schema = z.object({
  photo: nullableInput(z.instanceof(File)),
  name: nullableInput(z.string().min(1)),
  age: nullableInput(z.number().min(18), 'Please, provide age'), // with custom error message
});

export type FormInitial = z.input<typeof schema>; // { photo: File | null, name: string | null, age: number | null }
export type FormSubmit = z.output<typeof schema>; // { file: File, name: string, age: number }

There is also a separate issue, which describes why fatal: true is important here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
stale No activity in last 60 days
Projects
None yet
Development

No branches or pull requests

9 participants