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

Validating optional text inputs #310

Closed
glennreyes opened this issue Feb 4, 2021 · 28 comments
Closed

Validating optional text inputs #310

glennreyes opened this issue Feb 4, 2021 · 28 comments

Comments

@glennreyes
Copy link
Contributor

Considering an optional email text input: from user perspective I think the appropriate validation for this would be z.string().email().optional() which allows either undefined or a valid email.

In React, these types of inputs are almost never undefined. In fact, optional fields are considered empty strings.

What is the common way to achieve validating these type of fields correctly with zod? I have tried things like z.union([z.string().email, z.literal('')]) or refine, which felt a little unintuitive for a simple, yet very common optional field value.

I was hoping for either a configurable optional type that could allow empty strings as well or a dedicated empty type for a string.

Anyway, thank you for this great validation library, happy to help in any way!

@jasonadkison
Copy link

jasonadkison commented Mar 3, 2021

I'm facing the same challenges regarding form validations and came up with the following solution. It can be further abstracted as needed. Any thoughts?

import * as z from 'zod';

const optionalTextInput = (schema: z.ZodString) =>
  z
    .union([z.string(), z.undefined()])
    .refine((val) => !val || schema.safeParse(val).success);

const emailInputSchema = optionalTextInput(z.string().email());

const emails = [undefined, "", "[email protected]", null, "foo", 12345];

const result = emails.map((email) => ({
  email,
  isValid: emailInputSchema.safeParse(email).success
}));

console.log(result);
// [
//   {
//     "email": undefined,
//     "isValid": true
//   },
//   {
//     "email": "",
//     "isValid": true
//   },
//   {
//     "email": "[email protected]",
//     "isValid": true
//   },
//   {
//     "email": null,
//     "isValid": false
//   },
//   {
//     "email": "foo",
//     "isValid": false
//   },
//   {
//     "email": 12345,
//     "isValid": false
//   }
// ]

@colinhacks
Copy link
Owner

colinhacks commented Mar 9, 2021

@glennreyes I understand the feeling that this is strangely convoluted. But the source of that convolution is the messy, chaotic behavior of web forms. Zod provides a rigorous, consistent way of defining types, so it can be complex to represent weird edge cases like those surfaced by poorly designed web standards 🤷‍♂️ I think implementing a new method or overloading .optional to allow for empty strings isn't necessary given that there are already multiple ways to implement this, but I understand the feeling.

Will some of the recent developments in TS surrounding tuples and rest parameters, it will shortly be possible to chain together calls to .or() that let you build up unions piece by piece. This isn't implemented yet but here's what it'll look like:

z.string().email().optional().or(z.literal(''))

Which I think looks and feels a lot nicer than the z.union or refinement approaches.

But for now the approaches described by you and @jasonadkison are the right way to go. Because Zod is composable, you only have to define that schema once, then you can import it wherever you're defining forms.

@joshmedeski
Copy link

This was driving me crazy, thanks @colinhacks, appending .or(z.literal('')) worked for me too 😄

@ghost
Copy link

ghost commented Sep 14, 2021

If you want either the full string or undefined you can use this utility function:

import { z } from "zod"

const emptyStringToUndefined = z.literal("").transform(() => undefined)

export function asOptionalField<T extends z.ZodTypeAny>(schema: T) {
  return schema.optional().or(emptyStringToUndefined)
}
const form = createForm({
  schema: z.object({
    required: z.string().min(8),
    optional: asOptionalField(z.string().min(8))
  })
})

@nemanjam
Copy link

.or(z.literal('')) works for single .optional() but what about partial()? Does it mean it is completely unusable in React?

@mi-na-bot
Copy link

This thread is strangely tone deaf to an extremely common and obvious use-case, validating forms. Not sure how requiring copious boilerplate to make optional work improves zod or anything else.

I appreciate, maybe, preserving the sanctity of bare optional(), but can we have something like optional({allowEmpty:true})?

@JoshuaAmaju
Copy link

JoshuaAmaju commented Aug 19, 2022

This thread is strangely tone deaf to an extremely common and obvious use-case, validating forms. Not sure how requiring copious boilerplate to make optional work improves zod or anything else.

I appreciate, maybe, preserving the sanctity of bare optional(), but can we have something like optional({allowEmpty:true})?

@MinervaBot It's strange you feel entitled, like you're paying for zod

@ollyde
Copy link

ollyde commented Sep 22, 2022

Was their ever a resolution for this?
We have a use case where optional isn't acting as it should (undefined or other value)

For example

export const fileLinkZod = z.object({
  url: z.string().url(),
  originalUrl: z.string().url().optional(),
  name: z.string().min(0).max(2024),
  mimeType: z.string().optional(),
});

The input originalUrl can be undefined or URL.

At the moment its not possible to define the class

export class FileLink {
  url!: string;
  originalUrl?: string;
  name!: string;
  mimeType?: string;
}

:-)

Not sure what to do here with Zod.

@valerius21
Copy link

I am also encountering this issue. I love zod for it's conciseness. It's confusing to encounter unexpected behaviour like this.

@AlexanderHott
Copy link

AlexanderHott commented Nov 11, 2022

+1 to this. Maybe there could be another method .optionalWhenFalsy() as to not complicate .optional()

@wottpal
Copy link

wottpal commented Feb 11, 2023

Is z.string().email().optional().or(z.literal('')) still the way to go here?

optional({allowEmpty:true}) would be dope though :)

@jordiyapz
Copy link

For me I use the length minimal: z.string().min(0)

@brogrammer-codes
Copy link

Is z.string().email().optional().or(z.literal('')) still the way to go here?

This worked for me in setting a an optional URL url: z.string().min(0).url("Enter a URL").max(255).or(z.literal('')),

@Amorim33
Copy link

Currently having the same problem with optional urls

@mi-na-bot
Copy link

It looks the the .or(z.literal('')) thing is supported now, which IMO is as good/readable as a parameter for optional().

@adaboese
Copy link

.optional().or(z.literal('')), is perfect! Thank you

@AuthorProxy
Copy link

AuthorProxy commented Dec 16, 2023

@adaboese it is totally not perfect, the same strange magic moves with simple things, the same as .min(1, "This field is required")

@John-Dennehy
Copy link

How is this considered an edge-case? The current implementation of optional() clearly isn't working as many users expect, regardless of the underlying reasons with the web standards. At a bare minimum this should be highlighted on the docs if unwilling to amend or add an alternative optional() that covers this use case?

@uuouter
Copy link

uuouter commented Jan 12, 2024

Was also confused by this. It looks like the whole .optional() is obsolete in this case:

logo: z.string().url().or(z.literal('')),

@o-az
Copy link

o-az commented Feb 28, 2024

foo: z
  .string()
  .optional()
  .transform(value => value || undefined)
  .refine(
    value => {
      if (!value) return true
      // remaining validation
    },
    { message: 'lorem ipsum' }
  )

@elie-delorme
Copy link

None of the solutions provided above seem to allow providing custom error messages.
For example, I wasn't able to make this validate as expected while allowing empty values with only zod library functions.

foo: z.number({ invalid_type_error: "must be a number", }).min(1, { message: "must be over 1" })

I ended creating a "emptyOr" helper function that wraps the whole of it, such as:

foo: emptyOr(z.number({ invalid_type_error: "must be a number", }).min(1, { message: "must be over 1" }))

@mi-na-bot
Copy link

@elie-delorme The method with .or() should pass along whatever error message it lands on.

@devDanielCespedes
Copy link

@glennreyes I understand the feeling that this is strangely convoluted. But the source of that convolution is the messy, chaotic behavior of web forms. Zod provides a rigorous, consistent way of defining types, so it can be complex to represent weird edge cases like those surfaced by poorly designed web standards 🤷‍♂️ I think implementing a new method or overloading .optional to allow for empty strings isn't necessary given that there are already multiple ways to implement this, but I understand the feeling.

Will some of the recent developments in TS surrounding tuples and rest parameters, it will shortly be possible to chain together calls to .or() that let you build up unions piece by piece. This isn't implemented yet but here's what it'll look like:

z.string().email().optional().or(z.literal(''))

Which I think looks and feels a lot nicer than the z.union or refinement approaches.

But for now the approaches described by you and @jasonadkison are the right way to go. Because Zod is composable, you only have to define that schema once, then you can import it wherever you're defining forms.

I love you

@zirkelc
Copy link

zirkelc commented Nov 6, 2024

I think Colin mentioned on Twitter that Zod is supposed to be extensible via the prototype, we can extend the ZodString.prototype directly with an empty() function:

declare module 'zod' {
  interface ZodString {
    empty(): ZodUnion<[this, ZodLiteral<''>]>;
  }
}

z.ZodString.prototype.empty = function () {
  return this.or(z.literal(''));
};

// works
z.string().email().empty();

However, I didn't get it to work on a preceding .optional() like z.string().email().optional().empty();

@Ev357
Copy link

Ev357 commented Nov 18, 2024

I think Colin mentioned on Twitter that Zod is supposed to be extensible via the prototype, we can extend the ZodString.prototype directly with an empty() function:

declare module 'zod' {
  interface ZodString {
    empty(): ZodUnion<[this, ZodLiteral<''>]>;
  }
}

z.ZodString.prototype.empty = function () {
  return this.or(z.literal(''));
};

// works
z.string().email().empty();

However, I didn't get it to work on a preceding .optional() like z.string().email().optional().empty();

thx a lot ❤️❤️❤️

@Reiss-Cashmore
Copy link

Reiss-Cashmore commented Nov 26, 2024

Is this use case solved for in a less hacky way in the upcoming V4 release? This issue should not be closed IMO

@sovetski
Copy link

Any update please? The solution or(z.literal('')) don't looks as a clean solution

that-one-arab added a commit to that-one-arab/compass that referenced this issue Dec 31, 2024
I ran the project only to get a zod error that my `EMAILER_` fields need to contain at least 1 character.
Weirdly enough the `EMAILER_` env variables are optional.

This comes back to `zod`'s way of handling empty strings, based on this comment from the creator of `zod` [here](colinhacks/zod#310 (comment))

This PR implements the suggestion in the comment

This should prevent developer confusion when they have `EMAILER_` env variables represented as empty strings not working when running locally, which should accelerate their onboarding process.
@Rican7
Copy link

Rican7 commented Feb 11, 2025

This was quite the thread to find. I was also running into this issue, but most of the solutions suggested using unions (via .or), which I was unable to utilize because I'm using Zod with Sveltekit-Superforms, which has limitations with FormData when using unions.

In any case, I ended up finding a solution on Reddit that I tweaked a bit for my particular usage. Here it is if anyone else lands here with a similar use-case:

// I've stored this in an easily importable library location in SvelteKit
// Something like `src/lib/schemas/schema-utils.ts`

import { z } from 'zod';

export function emptyAsNull<T extends z.ZodTypeAny>(schema: T) {
	return z.preprocess((val) => {
		if (typeof val === 'string' && val === '') {
			return null;
		} else {
			return val;
		}
	}, schema);
}
// Somewhere else in your app...
import { z } from 'zod';
import { emptyAsNull } from '$lib/schemas/schema-utils';

export const exampleSchema = z.object({
	displayName: z.string().min(2), // Required, must be at least 2 characters
	bio: emptyAsNull(z.string().min(10).nullish()) // Empty field in form data parses as `null`, otherwise a string that requires at least 2 characters
});

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests