Translating zod errors with next-intl
Tags: webdev, nextjs, i18n, typescript
Published: 2024-03-05 16:00:40

> **tl;dr** - An approach to translate zod errors with [next-intl](https://github.com/amannn/next-intl)
> using a [zod](https://github.com/colinhacks/zod) error map.
> <br />
> - Adapt the zod error map and translations provided by [zod-i18n](https://github.com/aiji42/zod-i18n)
> - Usage example using a custom hook

<br />

When starting a new project with Next.js these days, [next-intl](https://github.com/amannn/next-intl) and [zod](https://github.com/colinhacks/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](https://github.com/aiji42/zod-i18n) can be used to achieve this for [i18next](https://github.com/i18next/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.

<br />

The core piece of zod-i18n is the higher-order function `makeZodI18nMap` (original [here](https://github.com/aiji42/zod-i18n/blob/a747c683f02751e62983a7fbf82f8c20bc8bb6b9/packages/core/src/index.ts#L63C14-L63C28)) which returns a [zod error map](https://zod.dev/ERROR_HANDLING?id=customizing-errors-with-zoderrormap). However, before using this function with next-intl we need make some adaptions.

<br />
<br />

## Getting started

<br />

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. 

<br />
<br />

### Adapting the error map

<br />

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:

```typescript
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,
        });
      }
      // ...
```

<br />

### Adapting the translations

<br />

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](https://unicode-org.github.io/icu/userguide/format_parse/messages/) 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

```json
"Number must be exactly {{minimum}}"
“{{path}} must be exactly {{minimum}}"
```

becomes

```json
"{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.

<br />
<br />

### Wrapping it up

<br />

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:

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

<br />

## Using the translations

<br />

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

```typescript
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](https://github.com/gcascio/next-intl-zod).