Translating zod errors with next-intl

4 min read

tl;dr - An approach to translate zod errors with next-intl using a zod error map.


  • Adapt the zod error map and translations provided by zod-i18n
  • Usage example using a custom hook
  • Full example

When starting a new project with Next.js these days, next-intl and zod are my go to libraries for internationalization and schema validation, respectively. Of course, when using zod for client-facing validations I would like to translate potential error messages. The package zod-i18n can be used to achieve this for i18next, a popular alternative internationalization library. This means that by using this library as starting point one can quickly achieve zod translation with next-intl.


The core piece of zod-i18n is the higher-order function makeZodI18nMap which returns a zod error map. However, before using this function with next-intl we need make some adaptions.



Getting started


On the code side, what differs between the two packages is mainly the translation functions. Without going into detail, the changes boil down to removing the properties from the translation function which next-intl does not support. Additionally, we need replace the namespace logic used by i18next.



Adapting the error map


When zod encounters a problem when validating a schema, the error map receives an issue to process. This issue carries a path attribute which points to the field which caused the issue. zod-i18n leverages namespaces to translate these paths. With next-intl we need to take a different approach. We simply pass a second translation function tForm to makeZodI18nMap which translates the path and passes it to the original translation function:

export const makeZodI18nMap: MakeZodI18nMap = (option) => (issue, ctx) => {
  const { t, tForm } = {
    ...option,
  };

  // ...

  const path = issue.path.length > 0 && !!tForm
    ? { path: tForm(issue.path.join('.') as any) }
    : {};

  switch (issue.code) {
    case ZodIssueCode.invalid_type:
      if (issue.received === ZodParsedType.undefined) {
        message = t('errors.invalid_type_received_undefined', {
          ...path,
        });
      }
      // ...

Adapting the translations


The next bit to tackle is the translations of the zod errors. Fortunately, both packages use a similar approach for handling translations, namely, json files. The relevant difference is that next-intl uses the ICU message syntax while i18next does not. When adapting the translation, we will make use of the path property we pass to the translation function. For example, the two messages used by i18next

"Number must be exactly {{minimum}}"
{{path}} must be exactly {{minimum}}"

becomes

"{path, select, missingTranslation {Number} other {{path}}}  must be exactly {minimum}",

Here we use the ICU select syntax to either pick the translation for the path or the literal word “Number” when the path does not match "missingTranslation". There is one downside to this approach, though. To make this selection possible we need to explicitly translate missing translations of paths to "missingTranslation" otherwise the translation key will be shown instead.



Wrapping it up


Only one part is missing now, passing the error map to zod when we want to use it. For this we can write a simple hook:

export const useI18nZodErrors = () => {
  const t = useTranslations('zod');
  const tForm = useTranslations('form');
  z.setErrorMap(makeZodI18nMap({ t, tForm }));
};

Using the translations


With this setup we can finally translate or zod error messages, like in this example:

const schema = z.object({
  username: z.string().min(3),
  age: z.number().min(18),
});

type Schema = z.infer<typeof schema>;

export const ExampleForm = () => {
  useI18nZodErrors();
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<Schema>({
    resolver: zodResolver(schema),
    defaultValues: {
      username: '',
      age: 0,
    }
  });

  return (
    <form onSubmit={handleSubmit((d) => console.log(d))}>
      <input {...register('username')} />
      {errors.username?.message && <span>{errors.username.message}</span>}
      <input type="number" {...register('age', { valueAsNumber: true })} />
      {errors.age?.message && <span>{errors.age?.message}</span>}
      <input type="submit" />
    </form>
  );
};

Some improvements could be made but the result is satisfactory and I have been using it in several projects. You can find the full code on github.