zod icon indicating copy to clipboard operation
zod copied to clipboard

Allow Custom Errors on any value?

Open bradennapier opened this issue 3 years ago • 31 comments

Any reason not to allow defining the last argument for any value as an error message to allow overriding the error message for that property easily? The current method is not really ideal, especially when you have many string props but need different errors.

A couple nice ways to handle it:

Allow Last Param to be Error Message

Would also accept a custom error that it would throw instead of ZodError (similar to Joi)

const TestSchema = z.object({
	three: z.literal('hi').optional(),
	one: z.number(),
	two: z.literal(3, 'that isnt 3 dummy'),
	four: z.string('string not whatever you gave!'),
	five: z.date()
})

Add a .error() option like Joi

const TestSchema = z.object({
	three: z.literal('hi').optional(),
	one: z.number(),
	two: z.literal(3).error(new Error('that isnt 3 dummy')),
	four: z.string().optional().error(new Error('string not whatever you gave!')),
	five: z.date()
})

For the VSCode extension I am working on, these styles add the benefit that jsdoc can be used to transfer over error messaging easily:

bradennapier avatar Jul 25 '20 10:07 bradennapier

This is on the roadmap. Though it'll likely be an object instead of a string to leave room for future API additions.

z.string({ message: "That's not a string!" });

colinhacks avatar Jul 27 '20 07:07 colinhacks

Agreed, good to know it's coming !

bradennapier avatar Jul 27 '20 07:07 bradennapier

It is also a very useful feature to customize the error message of e.g. .union(), .optional(), and .nullable().

Currently z.string().nullable().parse(undefined) would throw

Issue #0: invalid_union at
Invalid input

which is very confusing for the user. It would be great if something like z.string().nullable({ message: "This field can be a string or null, but not undefined." }).parse(undefined) could produce:

Issue #0: invalid_union at
This field can be a string or null, but not undefined.

hesyifei avatar Jul 31 '20 02:07 hesyifei

What about yup#setLocale API? It seems to be easier to handle global translations for both primitives and their methods

amankkg avatar Mar 18 '21 08:03 amankkg

Hi, I noticed that if custom message is specified, the errorMap function will never be called. https://github.com/colinhacks/zod/blob/630a2aa082fdcc5c1ebd76f8aa819bf07fd1f27f/src/helpers/parseUtil.ts#L88-L92

I have a use case where I want to use localization keys as custom messages and handle them inside the errorMap function which is currently not possible.

Would it be possible to update the implementation to always go through the errorMap function ? For example:

 params.errorMap(errorArg, { 
   data: params.data, 
   defaultError: errorData.message || defaultError.message, 
 }).message,

vsimko avatar May 08 '21 19:05 vsimko

I think hard-coding an error message should override the error map in this case. Based on my limited understanding of what you're trying to do, it sounds like an anti-pattern. Localization settings should be contextual, not baked into schemas. The recommended approach here is to create a separate error map for each language and either pass it as a parameter to .parse() or use z.setErrorMap() to set it globally. Let me know if I'm misunderstanding something.

colinhacks avatar May 15 '21 22:05 colinhacks

What if the schema is defined on a per-form basis (e.g react-hook-form) and I need to localize error messages (react-i18next).

// schema definition: notice the translation using t("...")
const ZMySchema = z.object({
  name: z.string().nonempty().regex(/^[A-Za-z ]$/, t("users:form.validNameRequired")),
});

// now registering the schema to the form
const form = useForm({ resolver: zodResolver(ZMySchema) });

vsimko avatar May 17 '21 07:05 vsimko

Until I find a better solution, I implemented the following workaround in my code:

import i18n, { TFunction } from "i18next";

class TranslatedString extends String {
  toString(): string {
    return i18n.t(super.toString());
  }
}

/**
 * Use this for localization of zod messages.
 * The function has to be named `t` because it mimics the `t` function from `i18next`
 * thus our IDE provides code completion and static analysis of translation keys.
 */
export const t: TFunction = (key: string) => ({ message: new TranslatedString(key) });

The trick is to defer the evaluation of the t() function until the string is printed which could have also been achieved if zod accepted a function as a message.

vsimko avatar May 17 '21 08:05 vsimko

I'm also looking forward to this - I'm using zod in forms, and if a user fails to enter a required field, i'd rather it say something like "X is required" instead of Expected number, received nan, but at the moment I don't see a good way to add that requiremnet.

osdiab avatar May 18 '21 11:05 osdiab

@osdiab you can globally modify every error Zod creates with Error Maps: https://github.com/colinhacks/zod/blob/master/ERROR_HANDLING.md#customizing-errors-with-zoderrormap

colinhacks avatar May 26 '21 00:05 colinhacks

That's an alright workaround! One thing though is that it's a little too imprecise for me to necessarily make a correct error message - for I have this number input field and if it's empty then what gets passed to Zod is NaN, which may happen for other invalid inputs; but I can't distinguish that from just an empty input based on the context object that's provided, where it's already been parsed to NaN. So it's possible for me to end up saying Required when really the issue is please enter a valid number. Can be worked around by clever copy or just some custom code but the hope is to minimize the custom code necessary for any situation, so it leaves a little precision to be desired.

Also not a huge fan of the global config, would much rather this kind of logic happen at a more local level.

EDIT: just working around it by using logic in the component to render a different value based on the actual input value, accesed via the error object's ref field. Not ideal but better than the alternative.

osdiab avatar May 26 '21 07:05 osdiab

I think the issue with ZodErrorMap is theres often cases where you want a certain schema to return a different error for that specific schema. The limit of having only a message object in the granular case precludes you from having error objects returned by zod, for later parsing by an application to react to those errors. E.g. being able to return an object of an arbitary shape.

Ultimately, only being able to return a message string in the non-global case precludes you from having custom errors for a schema where you want to do i18n later or if you need to react to those errors based on some key in your application for whatever reason. Lets say if you wanted to provide a button to the user to help them fix a certain error type. Tbh, I do think these use cases are mostly Ui based.

I'd suggest what would solve this is something like:

const ZMySchema = z.object({
  name: z.string().nonempty().regex(/^[A-Za-z ]$/, ({path}) => ({code: MY_CUSTOM_CODE, path)),
});

I'll add that you can workaround this now by serialising your custom error obj into the message key but thats obviously very dirty. You also dont have access to the path so have to repeat it in your error obj.

Also want to use the opportunity to express how great this library is. Its actually fantastic to be able to write a schema and infer the type for use in a form lib like Formik. Really great stuff that deserves attention.

adam-thomas-privitar avatar May 26 '21 10:05 adam-thomas-privitar

In my case given that the error happens at the number() stage I just want to be able to do this:

z.number({ message: "Make it a number, doofus!" });

or alternatively for more complicated circumstances,

z.number({ message: ({ issue, ctx }) => makeBadNumberErrorMessage({ issue, ctx }) })

To me this is a logical UX choice, but I haven't been using Zod for that long so I could be missing something that makes this impractical.

osdiab avatar May 26 '21 11:05 osdiab

I have created a PR #543 that your could set required or invalid messages to string, number and boolean. Would like to hear from you guys on ways to improve it.

YimingIsCOLD avatar Jul 23 '21 15:07 YimingIsCOLD

Zod is really super cool, but I also stumbled upon this issue here while figuring out how to use React Hook Form resolvers together with localized error messages using i18next. The API with a message property (like z.string().min(5, { message: "Must be 5 or more characters long" }) is quite ok, but it must be possible to pass in an object as a message. Otherwise, it won't be possible to translate complex messages with interpolations in them. Is this still planned?

medihack avatar Aug 23 '21 12:08 medihack

Zod brought me out of two days of depression after going through several other libraries.

At the moment this is the only problem I have encountered. Against the background of everything else, this is a mere trifle, but nevertheless I am also waiting for a solution to this problem.

dartess avatar Sep 01 '21 12:09 dartess

Let me share my latest workaround for this (updated since May 17):

// file: zod-translation.ts
import i18n, { TOptions } from "i18next";

class TranslatedString extends String {
  private readonly restArgs: (TOptions | string)[];

  constructor(private readonly key: string, ...restArgs: (TOptions | string)[]) {
    // if we used something else than an empty string here,
    // we would see the string being transformed to an array of chars
    // (seen in IntelliJ debugger)
    super("");

    this.restArgs = restArgs;
  }

  toString(): string {
    return i18n.t(this.key, ...this.restArgs);
  }
}

/**
 * Use this for localization of zod messages.
 * The function has to be named `t` because it mimics the `t` function from `i18next`
 * thus our IDE provides code completion and static analysis of translation keys.
 */
export function t(
  key: string,
  options: { path: string | string[] } | Record<string, unknown> = {}
): { message: string; path?: string[] } {
  const message: string = (new TranslatedString(key, options) as unknown) as string;
  const { path } = options;

  if (path === undefined) return { message };

  return {
    message,
    path: typeof path === "string" ? [path] : (path as string[]),
  };
}

Example usage:

import { t } from "./zod-translation.ts";

export const ZValidEncryptionKey = z
  .string()
  .regex(/^[a-zA-Z0-9]*$/, t("error.ZValidEncryptionKey.noSpacesAllowed"))
  .nullable();

vsimko avatar Sep 01 '21 16:09 vsimko

It would be cool if there were community translation files for common errors, where we would import 'zod/locale/pt'; and globally or on parse we would enter the language code. dayjs works like it.

They would be dicts from errorId to the translated message.

Similar for custom errors, loading them would register the errorIds to their messages. They could have the $x pattern for the values in the strings like $1 must be greater than $2!, and on the schemas, they would be selected and populated via ~ ('myError.invalidABC', [var1, var2])

SrBrahma avatar Feb 08 '22 04:02 SrBrahma

Similar to yup which have a community driven package: yup-locales

LoicMahieu avatar Apr 07 '22 13:04 LoicMahieu

Similar to yup which have a community driven package: yup-locales

This is really good. @colinhacks can you take a look?

SrBrahma avatar Apr 07 '22 18:04 SrBrahma

Landed here while working on an issue today where my react app with i18next translated literals were not getting used. I tried @vsimko's solution, but found it didn't work with i18next-parser (which I'm using to extract translations). The root issue is that the schema is created before i18next gets initialized (I'm doing so in a hook in the root component). I found an easy alternative which is to just move the schema code and its type inside of the component:

//react functional component
export const LoginForm = () => {

    //zod schema with literal strings to be translated with i18next
    const schema = z.object({
    username: z.string()
      .nonempty(t`Email is required`)
      .email(),
    password: z.string()
      .nonempty(t`Password is required`)
      .min(8, t`Password must be at least 8 characters`)
  });
  
  type Schema = z.infer<typeof schema>;

  //react-hook-form useForm hook w/ zodResolver
  const {register, handleSubmit, formState: {errors}} = useForm<Schema>({
    resolver: zodResolver(schema)
  });
...rest of component
}

Previously, my schema object and type were above the LoginForm component itself and I only got the default messages. Now I have full type safety and localized validation messages, which is awesome. Hope this helps someone else.

btebbens avatar May 06 '22 19:05 btebbens

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 Jul 05 '22 19:07 stale[bot]

Up

SrBrahma avatar Jul 05 '22 23:07 SrBrahma

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 04 '22 01:09 stale[bot]

Up

SrBrahma avatar Sep 04 '22 08:09 SrBrahma

Here is my (relatively simple) solution. Similar to @btebbens I build the schemas when I need them. But as I also use them in resolvers on the server (in my case Blitz.js, but tRPC works similarly) I put them in an own file where they can be built with and without the t function.

Simple example:

// app/core/validations.ts
const buildCreateProductSchema = (t?: TFunction) => z.object({
  name: z.string().regex(/^[a-zA-Z]*$/, t && { message: t("formErrors.invalid") })
})

medihack avatar Sep 19 '22 13:09 medihack

up

CharlesOnAir avatar Oct 19 '22 16:10 CharlesOnAir

Hmmm just stumbled upon a usecase where we need a custom error for invalid_literal values when checking literals. :/

bombillazo avatar Oct 20 '22 05:10 bombillazo

I've been trying to create schema with i18n error messages. Ended up on this solution:

const schemaFactory = (t: TFunction): z.ZodType<ApiLoginRequest> =>
  z.object({
    email: z.string().email({ message: t('Please enter valid email') }),
    password: z
      .string()
      .min(MIN_PASSWORD_LENGTH, t('Minimum {{chars}} characters', { chars: MIN_PASSWORD_LENGTH }))
      .max(MAX_PASSWORD_LENGTH, t('Maximum {{chars}} characters', { chars: MAX_PASSWORD_LENGTH })),
  });

export default function LoginFormContainer() {
  const { t } = useTranslation();
  // memoize the schema to prevent recreating on each render
  const schema = useMemo(() => schemaFactory(t), [t]);
  const { register, handleSubmit, reset } = useForm({ defaultValues, resolver: zodResolver(schema) });

  // ...rest of your component

Hope it might help someone

deivuss331 avatar Oct 26 '22 17:10 deivuss331

I've been trying to create schema with i18n error messages. Ended up on this solution:

const schemaFactory = (t: TFunction): z.ZodType<ApiLoginRequest> =>
  z.object({
    email: z.string().email({ message: t('Please enter valid email') }),
    password: z
      .string()
      .min(MIN_PASSWORD_LENGTH, t('Minimum {{chars}} characters', { chars: MIN_PASSWORD_LENGTH }))
      .max(MAX_PASSWORD_LENGTH, t('Maximum {{chars}} characters', { chars: MAX_PASSWORD_LENGTH })),
  });

export default function LoginFormContainer() {
  const { t } = useTranslation();
  // memoize the schema to prevent recreating on each render
  const schema = useMemo(() => schemaFactory(t), [t]);
  const { register, handleSubmit, reset } = useForm({ defaultValues, resolver: zodResolver(schema) });

  // ...rest of your component

Hope it might help someone

Main problem with such schemaFactory initiated in components is that you cannot export and reuse this schema outside of this component. This defeats the main "goal is to eliminate duplicative type declarations"

titenis avatar Nov 23 '22 12:11 titenis