Skip to content

This issue was moved to a discussion.

You can continue the conversation there. Go to discussion →

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

z.coerce.number() defaults empty strings to 0 #2461

Closed
srowe0091 opened this issue May 26, 2023 · 14 comments
Closed

z.coerce.number() defaults empty strings to 0 #2461

srowe0091 opened this issue May 26, 2023 · 14 comments

Comments

@srowe0091
Copy link

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 z.coerce.number() works fine with the inputs not complaining about types, but when the validation runs, empty strings are getting converted to a 0. The state of the form shows an empty string. This causes a weird behavior where if the field is required, validation passes because the schema sees a 0 which gives a false positive.

@colinhacks
Copy link
Owner

Zod just uses the Number function from JavaScript to do coercion, so JavaScript is responsible for any weirdness.

What do you want z.coerce.number().parse("") to return?

@srowe0091
Copy link
Author

Thanks for the reply @colinhacks, I was able to get around this by creating an actual NumberInput component. I have a feeling that Zod is built to in a way to make us devs write components in the right way, as opposed to just work out of the box for Inputs.

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.

@scotttrinh
Copy link
Collaborator

Thanks for the reply @colinhacks, I was able to get around this by creating an actual NumberInput component. I have a feeling that Zod is built to in a way to make us devs write components in the right way, as opposed to just work out of the box for Inputs.

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 unknown into an known type providing some useful transformations on valid input data while giving you some tools to use the parsed type in the rest of your code.

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 number, what would you expect "" to map to? Some valid values that I can think of are 0, NaN, -1, -Infinity, Infinity, or perhaps it should refuse to coerce. Of all of those options, 0 is the closest to being the natural fit since that's what JavaScript's Number constructor casts "" as.


In my view, form libraries should be doing the heavy lifting for you here: setting "empty" values to null and doing whatever transformations to the values are required to make using parsing/validation libraries like Zod give sensible behavior based on the HTML Form use-case.

@srowe0091
Copy link
Author

@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 NumberInput component that overrides the onChange and then casts the string in the event.target.value into a number Number(event.target.value). and if its an empty string "" then i return an empty string, which will then get processed in the validation saying its the wrong type, but i just override that message saying Invalid input

@alainfonhof
Copy link

We had a similar issue and settled on z.coerce.number().positive()

@daniel-johnsson
Copy link

Several UI libraries use "" for number inputs, would be great if coerce would parse empty string to undefined to not trigger the invalid_type_error and rather required_error if not set to optional.

My current workaround to the problem is to extend the ZodNumber class :

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 processCreateParams where a exported function as well so I did not have to copy it.

@jacknevitt
Copy link

jacknevitt commented Aug 24, 2023

Is there a way to validate that the is not an empty string before coercing?
My best guess is to use .pipe?

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()

@colinhacks
Copy link
Owner

@jacknevitt That would work but z.preprocess() would be a little more explicit.

@jacknevitt
Copy link

Oh yeah, of course. I had forgotten about that one. Thanks!

@maximeburri
Copy link

For anyone interested, I found this solution (but without using coerce):

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 number | null and handles cases:

  • '' parses null
  • '100' parses 100
  • '100a' will fail when parsing with Invalid number

@LouisLecouturier
Copy link

LouisLecouturier commented Sep 10, 2023

Using @maximeburri approach, I made a function that wraps the z.number() type you'd like :

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

@JacobWeisenburger
Copy link
Contributor

@srowe0091,
Has your question been addressed? If so, I'd like to close this issue.

@JacobWeisenburger JacobWeisenburger added the closeable? This might be ready for closing label Sep 26, 2023
@edersonlucas
Copy link

Using @maximeburri approach, I made a function that wraps the z.number() type you'd like :

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

Thank you, that's exactly what I was looking for.

@srowe0091
Copy link
Author

@srowe0091, Has your question been addressed? If so, I'd like to close this issue.

@JacobWeisenburger Yes its been addressed, thank you!

@JacobWeisenburger JacobWeisenburger removed the closeable? This might be ready for closing label Sep 27, 2023
Repository owner locked and limited conversation to collaborators Sep 27, 2023
@JacobWeisenburger JacobWeisenburger converted this issue into discussion #2814 Sep 27, 2023

This issue was moved to a discussion.

You can continue the conversation there. Go to discussion →

Labels
None yet
Projects
None yet
Development

No branches or pull requests

10 participants