!hero
zod-i18n-map
Translate Zod error messages with i18next.
Zod v4 ships built-in locales (z.config(z.locales.ja())). Those are global and use fixed wording. Use this library when you need any of the following:
- Your app chooses the language: i18next picks it, and you can switch per user or per request on the server.
- You own the wording: the translations are plain i18next JSON files in your repository.
- Field names in messages: for example "Email is required" instead of "Required".
- Your own translation keys on any schema, check or refinement, with access to values like
{{count}}.
Using Zod v3? Stay on zod-i18n-map@2.
Quick start
npm install zod-i18n-map i18next zod
npx zod-i18n-map add en ja # writes locales/en/zod.json and locales/ja/zod.json
The translation files are not included in the npm package. The CLI copies them into your project, and from then on you edit them like any other resource file.
import i18next from "i18next";
import * as z from "zod";
import { zodI18nMap } from "zod-i18n-map";
import ja from "./locales/ja/zod.json";
await i18next.init({
lng: "ja",
resources: { ja: { zod: ja } },
});
z.config({ customError: zodI18nMap });
z.email().safeParse("foo").error?.issues[0]?.message;
// => "メールアドレスの形式で入力してください。"
If a key is missing, Zod's own message is used instead. That is either English or the built-in Zod locale you configured with z.config(z.locales.xx()).
Translation files
npx zod-i18n-map list # available locales
npx zod-i18n-map add de fr # -> locales/{locale}/zod.json
npx zod-i18n-map add de --out public/locales/{locale}/zod.json
npx zod-i18n-map add de --force # overwrite an existing file
npx zod-i18n-map check locales/de/zod.json # validate: missing keys, unknown variables, plural forms
npx zod-i18n-map add de --format icu # ICU MessageFormat, for i18next-icu
- By default, files come from the Git tag that matches your installed version, so they always match the key layout your version expects. Use
--refto pick another source. - Use
--jsonto get machine-readable output. - The source files are in
locales/. You can also download them directly or load them at runtime from a CDN, e.g. with i18next-http-backend:
https://cdn.jsdelivr.net/gh/aiji42/[email protected]/locales/ja/zod.json
- If your app uses i18next-icu, use
--format icu. The files are converted to ICU MessageFormat:{name},{minimum, date}, and plurals merged into{count, plural, ...}. i18next contexts don't work with i18next-icu, so the_with_pathvariants are not used.{path}is still passed to every message.
Usage
Per request (servers, RSC, edge)
You don't have to set the error map globally. Zod v4 accepts an error map for each parse:
import { makeZodI18nMap } from "zod-i18n-map";
const error = makeZodI18nMap({ t: i18next.getFixedT(request.locale) });
const result = schema.safeParse(body, { error });
t can be any i18next-compatible translate function: i18next.t, getFixedT(...), or react-i18next's useTranslation().t.
Re-translating shown errors after a language switch
Messages are created when a value is parsed. If the user switches languages while errors are on screen (e.g. kept in react-hook-form state), translate the issues again when you render them:
import { translateIssue } from "zod-i18n-map";
{result.error.issues.map((issue) => <p key={issue.path.join(".")}>{translateIssue(issue)}</p>)}
This needs Zod 4.4 or later; on older versions it returns issue.message unchanged. It works because the map stores a small zodI18n field on each issue it handles, with the received type and any i18nKey. So the message can be rebuilt in full, field names included. Issues whose message came from your own error string are returned unchanged. With a custom map, use map.translate(issue).
Your own keys for specific fields
Use i18nKey (or .key() on a map you created) as the error of any schema or check. All the issue's values (count, minimum, maximum, path, ...) are available for interpolation:
import { i18nKey } from "zod-i18n-map";
// zod.json: { "password": { "too_short": "Use at least {{count}} characters" } }
const password = z.string().min(8, { error: i18nKey("password.too_short") });
// Keys from another namespace use i18next's ns:key syntax, extra values go in the 2nd argument
const email = z.email({ error: i18nKey("form:email_invalid", { example: "[email protected]" }) });
For refinements, params.i18n works too:
z.string().refine((v) => v !== "admin", { params: { i18n: "username.reserved" } });
z.string().refine(isAllowed, {
params: { i18n: { key: "username.not_allowed", values: { name: "admin" } } },
});
If the custom key is missing, the default translation for that issue is used.
Field names in messages (handlePath)
If the issue has a path, the with_path context variant of a message is used when it exists. {{path}} holds the translated field name:
// locales/en/zod.json
{
"errors": {
"invalid_type_received_undefined": "Required",
"invalid_type_received_undefined_with_path": "{{- path}} is required"
},
"email": "Email address"
}
z.object({ email: z.email() }).parse({}); // => "Email address is required"
- Field names are looked up by the joined path (
user.email). If that key is missing, they are looked up again without array indices (items.0.name→items.name). If both are missing, the raw path is used. - Use
handlePath: { ns: "form", keyPrefix: "fields" }to keep labels elsewhere. With that setting,form:fields.user.emailis looked up. - Use
context: "..."to change the context suffix, orhandlePath: falseto turn this off.
Plurals
Messages that receive {{count}} use i18next plurals, so each language can use the right forms:
{ "inclusive_one": "At least {{count}} character", "inclusive_other": "At least {{count}} characters" }
Namespaces
Messages are looked up in the zod namespace by default. Use a different namespace for each audience, for example end-user forms and developer-facing APIs:
z.config({ customError: makeZodI18nMap({ ns: "formErrors" }) });
// several namespaces are searched in order
makeZodI18nMap({ ns: ["formErrors", "zod"] });
API
makeZodI18nMap(options?: {
t?: TranslateFunction; // default: i18next.t
ns?: string | readonly string[]; // default: "zod"
handlePath?: false | {
context?: string; // default: "with_path"
ns?: string | readonly string[]; // default: same as ns
keyPrefix?: string;
};
}): ZodI18nMap
zodI18nMap: ZodI18nMap // makeZodI18nMap() with defaults
zodI18nMap.key(key, values?) // error map that tries key first
i18nKey(key, values?) // = zodI18nMap.key
zodI18nMap.translate(issue) // re-translate an existing issue in the current language
translateIssue(issue) // = zodI18nMap.translate
ZodI18nMap is a regular Zod v4 error map. You can use it in z.config({ customError }), in a parse's { error }, or in a schema's { error }.
Message keys
| Zod issue | Key | Variables |
| --- | --- | --- |
| invalid_type | errors.invalid_typeerrors.invalid_type_received_undefinederrors.invalid_type_received_null | expected, received (translated via types.*) |
| invalid_value | errors.invalid_valueerrors.invalid_value_single (one allowed value, e.g. z.literal) | options / expected |
| invalid_format | errors.invalid_format., otherwise errors.invalid_format.default | format (translated via formats.*), pattern, prefix, suffix, includes |
| too_small / too_big | errors.too_small.. Origins are string, number (incl. int, bigint), array, set, map, file and date. | minimum / maximum, count |
| not_multiple_of | errors.not_multiple_of | divisor |
| unrecognized_keys | errors.unrecognized_keys | keys, count |
| invalid_key / invalid_element | errors.invalid_key / errors.invalid_element | origin |
| invalid_union | errors.invalid_unionerrors.invalid_union_discriminator | options, discriminator |
| custom | errors.custom, or the key given via params.i18n | your values |
Every message also receives path when handlePath is on. See locales/en/zod.json for the full reference file.
Migrating from v2
- Zod v4 is required. With
[email protected], import fromzod/v4. - Setup: replace
z.setErrorMap(zodI18nMap)withz.config({ customError: zodI18nMap }). - Translation files are no longer imported from the package. Replace
import translation from "zod-i18n-map/locales/ja/zod.json"with a file you get fromnpx zod-i18n-map add ja. - Keys follow Zod v4 issue codes:
invalid_string. → invalid_format.
- validations. → formats.
- invalid_enum_value / invalid_literal → invalid_value / invalid_value_single
- {{multipleOf}} → {{divisor}}
- invalid_date, invalid_arguments, invalid_return_type, invalid_intersection_types, not_finite and the too_small.set / too_big.set "Invalid input" placeholders were removed or replaced, because Zod v4 no longer emits them.
- If you customized a v2 file, start from the new file and copy your wording over. npx zod-i18n-map check lists the keys you are missing.
- Locale codes:
hr-HR→hr,uk-UA→uk,pt→pt-BR(it was always Brazilian Portuguese; European Portuguese is nowpt-PT). (You choose the file name, so any i18next language code works.)
Any language, with your coding agent
The translation files belong to you, so a language that isn't listed is one command away:
npx zod-i18n-map scaffold sw # copies the English file and prints translation rules for Swahili
...let your coding agent (or a human) translate locales/sw/zod.json...
npx zod-i18n-map check locales/sw/zod.json # verifies keys, {{variables}} and the plural forms Swahili needs
The rules that scaffold prints are written for agents. They cover which variables must stay intact, which plural suffixes the language requires (from Intl.PluralRules) and how to keep templates grammatical. check turns those rules into a pass/fail result the agent can loop on. Run check after editing any translation file, too.
For AI coding agents
llms.txt: the npm package includes a short, complete usage guide atnode_modules/zod-i18n-map/llms.txt.- Agent Skill:
npx zod-i18n-map skillinstallsSKILL.mdat.claude/skills/zod-i18n-map/. Use--outfor other agents' skill directories. With the skill installed, "localize our form validation errors" is something your agent knows how to do with this library. - CLI: every command is non-interactive, supports
--json, and signals failure through its exit code.
Contributing
See CONTRIBUTING.md. Translation fixes and new locales are especially welcome.