├── .eslintrc.json ├── next.config.mjs ├── global.d.ts ├── messages ├── en.json ├── de.json └── zod │ ├── de.json │ └── en.json ├── app ├── [locale] │ ├── page.tsx │ └── layout.tsx └── globals.css ├── middleware.ts ├── lib ├── useI18nZodErrors.ts └── zodErrorMap.ts ├── README.md ├── .gitignore ├── i18n.ts ├── tsconfig.json ├── package.json └── components └── ExampleForm.tsx /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | import createNextIntlPlugin from 'next-intl/plugin'; 2 | 3 | const withNextIntl = createNextIntlPlugin(); 4 | 5 | /** @type {import('next').NextConfig} */ 6 | const nextConfig = {}; 7 | 8 | export default withNextIntl(nextConfig); -------------------------------------------------------------------------------- /global.d.ts: -------------------------------------------------------------------------------- 1 | // Use type safe message keys with `next-intl` 2 | type GlobalMessages = typeof import('./messages/en.json'); 3 | type ZodMessages = typeof import('./messages/zod/en.json'); 4 | type Messages = GlobalMessages & ZodMessages; 5 | declare interface IntlMessages extends Messages {} -------------------------------------------------------------------------------- /messages/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "nav": { 3 | "english": "English", 4 | "german": "German" 5 | }, 6 | "home": { 7 | "title": "next-intl zod example" 8 | }, 9 | "form": { 10 | "username": "Username", 11 | "age": "Age" 12 | }, 13 | "customErrors": { 14 | "admin_username_error": "Nice try!" 15 | } 16 | } -------------------------------------------------------------------------------- /messages/de.json: -------------------------------------------------------------------------------- 1 | { 2 | "nav": { 3 | "english": "Englisch", 4 | "german": "Deutsch" 5 | }, 6 | "home": { 7 | "title": "next-intl zod Beispiel" 8 | }, 9 | "form": { 10 | "usernamex": "Nutzername", 11 | "age": "Alter" 12 | }, 13 | "customErrors": { 14 | "admin_username_error": "Netter Versuch!" 15 | } 16 | } -------------------------------------------------------------------------------- /app/[locale]/page.tsx: -------------------------------------------------------------------------------- 1 | import { ExampleForm } from '@/components/ExampleForm'; 2 | import {useTranslations} from 'next-intl'; 3 | import '../globals.css'; 4 | 5 | export default function Index() { 6 | const t = useTranslations('home'); 7 | return ( 8 | <> 9 |

{t('title')}

10 | 11 | 12 | ); 13 | } -------------------------------------------------------------------------------- /middleware.ts: -------------------------------------------------------------------------------- 1 | import createMiddleware from 'next-intl/middleware'; 2 | 3 | export default createMiddleware({ 4 | // A list of all locales that are supported 5 | locales: ['en', 'de'], 6 | 7 | // Used when no locale matches 8 | defaultLocale: 'en' 9 | }); 10 | 11 | export const config = { 12 | // Match only internationalized pathnames 13 | matcher: ['/', '/(de|en)/:path*'] 14 | }; -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | main { 2 | display: flex; 3 | justify-content: center; 4 | align-items: center; 5 | flex-direction: column; 6 | } 7 | 8 | form { 9 | display: flex; 10 | flex-direction: column; 11 | align-items: center; 12 | justify-content: center; 13 | gap: 1rem; 14 | } 15 | 16 | nav { 17 | display: flex; 18 | align-items: center; 19 | padding: 1rem; 20 | gap: 1rem; 21 | } -------------------------------------------------------------------------------- /lib/useI18nZodErrors.ts: -------------------------------------------------------------------------------- 1 | import { useTranslations } from 'next-intl'; 2 | import { z } from 'zod'; 3 | import { makeZodI18nMap } from './zodErrorMap'; 4 | 5 | export const useI18nZodErrors = () => { 6 | const t = useTranslations('zod'); 7 | const tForm = useTranslations('form'); 8 | const tCustom = useTranslations('customErrors'); 9 | z.setErrorMap(makeZodI18nMap({ t, tForm, tCustom })); 10 | }; -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Translating zod errors with next-intl 2 | 3 | This is code is a demonstration of how to translate zod errors with next-intl using a zod error map. For more information see this [blog post](https://www.gcasc.io/blog/next-intl-zod). 4 | 5 | ## Getting Started 6 | 7 | First, run the development server: 8 | 9 | ```bash 10 | npm run dev 11 | # or 12 | yarn dev 13 | # or 14 | pnpm dev 15 | # or 16 | bun dev 17 | ``` 18 | 19 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /i18n.ts: -------------------------------------------------------------------------------- 1 | import {notFound} from 'next/navigation'; 2 | import {getRequestConfig} from 'next-intl/server'; 3 | 4 | // Can be imported from a shared config 5 | const locales = ['en', 'de']; 6 | 7 | export default getRequestConfig(async ({locale}) => { 8 | // Validate that the incoming `locale` parameter is valid 9 | if (!locales.includes(locale as any)) notFound(); 10 | 11 | return { 12 | messages: { 13 | ...(await import(`./messages/${locale}.json`)).default, 14 | ...(await import(`./messages/zod/${locale}.json`)).default 15 | } 16 | }; 17 | }); -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "module": "esnext", 10 | "moduleResolution": "bundler", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "jsx": "preserve", 14 | "incremental": true, 15 | "plugins": [ 16 | { 17 | "name": "next" 18 | } 19 | ], 20 | "paths": { 21 | "@/*": ["./*"] 22 | } 23 | }, 24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 25 | "exclude": ["node_modules"] 26 | } 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "next-intl-zod", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@hookform/resolvers": "^3.3.4", 13 | "next": "14.1.1", 14 | "next-intl": "^3.9.1", 15 | "react": "^18", 16 | "react-dom": "^18", 17 | "react-hook-form": "^7.51.0", 18 | "zod": "^3.22.4" 19 | }, 20 | "devDependencies": { 21 | "@types/node": "^20", 22 | "@types/react": "^18", 23 | "@types/react-dom": "^18", 24 | "eslint": "^8", 25 | "eslint-config-next": "14.1.1", 26 | "typescript": "^5" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /app/[locale]/layout.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslations } from "next-intl"; 2 | import { NextIntlClientProvider, useMessages } from 'next-intl'; 3 | 4 | export default function LocaleLayout({ 5 | children, 6 | params: {locale} 7 | }: { 8 | children: React.ReactNode; 9 | params: {locale: string}; 10 | }) { 11 | const t = useTranslations('nav'); 12 | const messages = useMessages(); 13 | 14 | return ( 15 | 16 | 17 |
18 | 22 |
23 |
24 | 28 | {children} 29 | 30 |
31 | 32 | 33 | ); 34 | } -------------------------------------------------------------------------------- /components/ExampleForm.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useForm } from 'react-hook-form'; 4 | import { zodResolver } from '@hookform/resolvers/zod'; 5 | import * as z from 'zod'; 6 | import { useI18nZodErrors } from '@/lib/useI18nZodErrors'; 7 | 8 | const schema = z.object({ 9 | username: z 10 | .string() 11 | .min(3) 12 | .refine((value) => value !== 'admin', { params: { i18n: "admin_username_error" } }), 13 | age: z.number().min(18), 14 | }); 15 | 16 | type Schema = z.infer; 17 | 18 | export const ExampleForm = () => { 19 | useI18nZodErrors(); 20 | const { 21 | register, 22 | handleSubmit, 23 | formState: { errors }, 24 | } = useForm({ 25 | resolver: zodResolver(schema), 26 | defaultValues: { 27 | username: '', 28 | age: 0, 29 | } 30 | }); 31 | 32 | return ( 33 |
console.log(d))}> 34 | 35 | {errors.username?.message && {errors.username.message}} 36 | 37 | {errors.age?.message && {errors.age?.message}} 38 | 39 |
40 | ); 41 | }; -------------------------------------------------------------------------------- /messages/zod/de.json: -------------------------------------------------------------------------------- 1 | { 2 | "zod": { 3 | "errors": { 4 | "invalid_type": "{expected} erwartet, {received} erhalten", 5 | "invalid_type_received_undefined": "Darf nicht leer sein", 6 | "invalid_literal": "Ungültiger Literalwert, {expected} erwartet", 7 | "unrecognized_keys": "Unbekannte Schlüssel im Objekt: {keys}", 8 | "invalid_union": "Ungültige Eingabe", 9 | "invalid_union_discriminator": "Ungültiger Diskriminatorwert, {options} erwartet", 10 | "invalid_enum_value": "Ungültiger Enum-Wert. {options} erwartet, {received} erhalten", 11 | "invalid_arguments": "Ungültige Funktionsargumente", 12 | "invalid_return_type": "Ungültiger Funktionsrückgabewert", 13 | "invalid_date": "Ungültiges Datum", 14 | "custom": "Ungültige Eingabe", 15 | "invalid_intersection_types": "Schnittmengenergebnisse konnten nicht zusammengeführt werden", 16 | "not_multiple_of": "Zahl muss ein Vielfaches von {multipleOf} sein", 17 | "not_finite": "Zahl muss endlich sein", 18 | "invalid_string": { 19 | "email": "Ungültige {validation}", 20 | "emoji": "Ungültige {validation}", 21 | "url": "Ungültige {validation}", 22 | "uuid": "Ungültige {validation}", 23 | "ulid": "Ungültige {validation}", 24 | "ip": "Ungültige {validation}", 25 | "cuid": "Ungültige {validation}", 26 | "cuid2": "Ungültige {validation}", 27 | "regex": "Ungültig", 28 | "datetime": "Ungültiger {validation}", 29 | "startsWith": "Ungültige Eingabe: muss mit \"{startsWith}\" beginnen", 30 | "endsWith": "Ungültige Eingabe: muss mit \"{endsWith}\" enden" 31 | }, 32 | "too_small": { 33 | "array": { 34 | "exact": "{path, select, missingTranslation {Array} other {{path}}} muss genau {minimum} Element(e) enthalten", 35 | "inclusive": "{path, select, missingTranslation {Array} other {{path}}} muss mindestens {minimum} Element(e) enthalten", 36 | "not_inclusive": "{path, select, missingTranslation {Array} other {{path}}} muss mehr als {minimum} Element(e) enthalten" 37 | }, 38 | "string": { 39 | "exact": "{path, select, missingTranslation {String} other {{path}}} muss genau {minimum} Zeichen enthalten", 40 | "inclusive": "{path, select, missingTranslation {String} other {{path}}} muss mindestens {minimum} Zeichen enthalten", 41 | "not_inclusive": "{path, select, missingTranslation {String} other {{path}}} muss mehr als {minimum} Zeichen enthalten" 42 | }, 43 | "number": { 44 | "exact": "{path, select, missingTranslation {Zahl} other {{path}}} muss genau {minimum} sein", 45 | "inclusive": "{path, select, missingTranslation {Zahl} other {{path}}} muss größer oder gleich {minimum} sein", 46 | "not_inclusive": "{path, select, missingTranslation {Zahl} other {{path}}} muss größer als {minimum} sein" 47 | }, 48 | "set": { 49 | "exact": "Ungültige Eingabe", 50 | "inclusive": "Ungültige Eingabe", 51 | "not_inclusive": "Ungültige Eingabe" 52 | }, 53 | "date": { 54 | "exact": "Datum muss genau {minimum, date, short} sein", 55 | "inclusive": "Datum muss größer oder gleich {minimum, date, short} sein", 56 | "not_inclusive": "Datum muss größer als {minimum, date, short} sein" 57 | } 58 | }, 59 | "too_big": { 60 | "array": { 61 | "exact": "{path, select, missingTranslation {Array} other {{path}}} muss genau {maximum} Element(e) enthalten", 62 | "inclusive": "{path, select, missingTranslation {Array} other {{path}}} darf höchstens {maximum} Element(e) enthalten", 63 | "not_inclusive": "{path, select, missingTranslation {Array} other {{path}}} muss weniger als {maximum} Element(e) enthalten" 64 | }, 65 | "string": { 66 | "exact": "{path, select, missingTranslation {String} other {{path}}} muss genau {maximum} Zeichen enthalten", 67 | "inclusive": "{path, select, missingTranslation {String} other {{path}}} darf höchstens {maximum} Zeichen enthalten", 68 | "not_inclusive": "{path, select, missingTranslation {String} other {{path}}} muss weniger als {maximum} Zeichen enthalten" 69 | }, 70 | "number": { 71 | "exact": "{path, select, missingTranslation {Zahl} other {{path}}} muss genau {maximum} sein", 72 | "inclusive": "{path, select, missingTranslation {Zahl} other {{path}}} muss kleiner oder gleich {maximum} sein", 73 | "not_inclusive": "{path, select, missingTranslation {Zahl} other {{path}}} muss kleiner als {maximum} sein" 74 | }, 75 | "set": { 76 | "exact": "Ungültige Eingabe", 77 | "inclusive": "Ungültige Eingabe", 78 | "not_inclusive": "Ungültige Eingabe" 79 | }, 80 | "date": { 81 | "exact": "Datum muss genau {maximum, date, short} sein", 82 | "inclusive": "Datum muss kleiner oder gleich {maximum, date, short} sein", 83 | "not_inclusive": "Datum muss kleiner als {maximum, date, short} sein" 84 | } 85 | } 86 | }, 87 | "validations": { 88 | "email": "E-Mail-Adresse", 89 | "emoji": "Emoji", 90 | "url": "URL", 91 | "uuid": "UUID", 92 | "ulid": "ULID", 93 | "ip": "IP", 94 | "cuid": "CUID", 95 | "cuid2": "CUID2", 96 | "regex": "Regex", 97 | "datetime": "Datums- und Uhrzeitwert" 98 | }, 99 | "types": { 100 | "function": "Funktion", 101 | "number": "Zahl", 102 | "string": "String", 103 | "nan": "NaN", 104 | "integer": "Ganzzahl", 105 | "float": "Gleitkommazahl", 106 | "boolean": "Boolean", 107 | "date": "Datum", 108 | "bigint": "Bigint", 109 | "undefined": "Undefined", 110 | "symbol": "Symbol", 111 | "null": "Nullwert", 112 | "array": "Array", 113 | "object": "Objekt", 114 | "unknown": "Unknown", 115 | "promise": "Promise", 116 | "void": "Void", 117 | "never": "Never", 118 | "map": "Map", 119 | "set": "Set" 120 | } 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /messages/zod/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "zod": { 3 | "errors": { 4 | "invalid_type": "Expected {expected}, received {received}", 5 | "invalid_type_received_undefined": "Required", 6 | "invalid_literal": "Invalid literal value, expected {expected}", 7 | "unrecognized_keys": "Unrecognized key(s) in object: {keys}", 8 | "invalid_union": "Invalid input", 9 | "invalid_union_discriminator": "Invalid discriminator value. Expected {options}", 10 | "invalid_enum_value": "Invalid enum value. Expected {options}, received {received}", 11 | "invalid_arguments": "Invalid function arguments", 12 | "invalid_return_type": "Invalid function return type", 13 | "invalid_date": "Invalid date", 14 | "custom": "Invalid input", 15 | "invalid_intersection_types": "Intersection results could not be merged", 16 | "not_multiple_of": "Number must be a multiple of {multipleOf}", 17 | "not_finite": "Number must be finite", 18 | "invalid_string": { 19 | "email": "Invalid {validation}", 20 | "emoji": "Invalid {validation}", 21 | "url": "Invalid {validation}", 22 | "uuid": "Invalid {validation}", 23 | "ulid": "Invalid {validation}", 24 | "ip": "Invalid {validation}", 25 | "cuid": "Invalid {validation}", 26 | "cuid2": "Invalid {validation}", 27 | "regex": "Invalid", 28 | "datetime": "Invalid {validation}", 29 | "startsWith": "Invalid input: must start with \"{startsWith}\"", 30 | "endsWith": "Invalid input: must end with \"{endsWith}\"" 31 | }, 32 | "too_small": { 33 | "array": { 34 | "exact": "{path, select, missingTranslation {Array} other {{path}}} must contain exactly {minimum} element(s)", 35 | "inclusive": "{path, select, missingTranslation {Array} other {{path}}} must contain at least {minimum} element(s)", 36 | "not_inclusive": "{path, select, missingTranslation {Array} other {{path}}} must contain more than {minimum} element(s)" 37 | }, 38 | "string": { 39 | "exact": "{path, select, missingTranslation {String} other {{path}}} must contain exactly {minimum} character(s)", 40 | "inclusive": "{path, select, missingTranslation {String} other {{path}}} must contain at least {minimum} character(s)", 41 | "not_inclusive": "{path, select, missingTranslation {String} other {{path}}} must contain over {minimum} character(s)" 42 | }, 43 | "number": { 44 | "exact": "{path, select, missingTranslation {Number} other {{path}}} must be exactly {minimum}", 45 | "inclusive": "{path, select, missingTranslation {Number} other {{path}}} must be greater than or equal to {minimum}", 46 | "not_inclusive": "{path, select, missingTranslation {Number} other {{path}}} must be greater than {minimum}" 47 | }, 48 | "bigint": { 49 | "exact": "{path, select, missingTranslation {Number} other {{path}}} must be exactly {minimum}", 50 | "inclusive": "{path, select, missingTranslation {Number} other {{path}}} must be greater than or equal to {minimum}", 51 | "not_inclusive": "{path, select, missingTranslation {Number} other {{path}}} must be greater than {minimum}" 52 | }, 53 | "set": { 54 | "exact": "Invalid input", 55 | "inclusive": "Invalid input", 56 | "not_inclusive": "Invalid input" 57 | }, 58 | "date": { 59 | "exact": "Date must be exactly {minimum, date, short}", 60 | "inclusive": "Date must be greater than or equal to {minimum, date, short}", 61 | "not_inclusive": "Date must be greater than {minimum, date, short}" 62 | } 63 | }, 64 | "too_big": { 65 | "array": { 66 | "exact": "{path, select, missingTranslation {Array} other {{path}}} must contain exactly {maximum} element(s)", 67 | "inclusive": "{path, select, missingTranslation {Array} other {{path}}} must contain at most {maximum} element(s)", 68 | "not_inclusive": "{path, select, missingTranslation {Array} other {{path}}} must contain less than {maximum} element(s)" 69 | }, 70 | "string": { 71 | "exact": "{path, select, missingTranslation {String} other {{path}}} must contain exactly {maximum} character(s)", 72 | "inclusive": "{path, select, missingTranslation {String} other {{path}}} must contain at most {maximum} character(s)", 73 | "not_inclusive": "{path, select, missingTranslation {String} other {{path}}} must contain under {maximum} character(s)" 74 | }, 75 | "number": { 76 | "exact": "{path, select, missingTranslation {Number} other {{path}}} must be exactly {maximum}", 77 | "inclusive": "{path, select, missingTranslation {Number} other {{path}}} must be less than or equal to {maximum}", 78 | "not_inclusive": "{path, select, missingTranslation {Number} other {{path}}} must be less than {maximum}" 79 | }, 80 | "bigint": { 81 | "exact": "{path, select, missingTranslation {Number} other {{path}}} must be exactly {maximum}", 82 | "inclusive": "{path, select, missingTranslation {Number} other {{path}}} must be less than or equal to {maximum}", 83 | "not_inclusive": "{path, select, missingTranslation {Number} other {{path}}} must be less than {maximum}" 84 | }, 85 | "set": { 86 | "exact": "Invalid input", 87 | "inclusive": "Invalid input", 88 | "not_inclusive": "Invalid input" 89 | }, 90 | "date": { 91 | "exact": "Date must be exactly {maximum, date, short}", 92 | "inclusive": "Date must be smaller than or equal to {maximum, date, short}", 93 | "not_inclusive": "Date must be smaller than {maximum, date, short}" 94 | } 95 | } 96 | }, 97 | "validations": { 98 | "email": "email", 99 | "emoji": "emoji", 100 | "url": "url", 101 | "uuid": "uuid", 102 | "ulid": "ulid", 103 | "ip": "up", 104 | "cuid": "cuid", 105 | "cuid2": "cuid2", 106 | "regex": "regex", 107 | "datetime": "datetime" 108 | }, 109 | "types": { 110 | "function": "function", 111 | "number": "number", 112 | "string": "string", 113 | "nan": "nan", 114 | "integer": "integer", 115 | "float": "float", 116 | "boolean": "boolean", 117 | "date": "date", 118 | "bigint": "bigint", 119 | "undefined": "undefined", 120 | "symbol": "symbol", 121 | "null": "null", 122 | "array": "array", 123 | "object": "object", 124 | "unknown": "unknown", 125 | "promise": "promise", 126 | "void": "void", 127 | "never": "never", 128 | "map": "map", 129 | "set": "set" 130 | } 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /lib/zodErrorMap.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This error map is a modified version of the on used by zod-i18n 3 | * Checkout the original at: https://github.com/aiji42/zod-i18n 4 | */ 5 | 6 | import { 7 | ZodIssueCode, ZodParsedType, defaultErrorMap, ZodErrorMap, 8 | } from 'zod'; 9 | import { useTranslations } from 'next-intl'; 10 | 11 | const jsonStringifyReplacer = (_: string, value: unknown): unknown => { 12 | if (typeof value === 'bigint') { 13 | return value.toString(); 14 | } 15 | return value; 16 | }; 17 | 18 | function joinValues(array: T, separator = ' | '): string { 19 | return array 20 | .map((val) => (typeof val === 'string' ? `'${val}'` : val)) 21 | .join(separator); 22 | } 23 | 24 | const isRecord = (value: unknown): value is Record => { 25 | if (typeof value !== 'object' || value === null) return false; 26 | 27 | // eslint-disable-next-line no-restricted-syntax 28 | for (const key in value) { 29 | if (!Object.prototype.hasOwnProperty.call(value, key)) return false; 30 | } 31 | 32 | return true; 33 | }; 34 | 35 | const getKeyAndValues = ( 36 | param: unknown, 37 | defaultKey: string, 38 | ): { 39 | values: Record; 40 | key: string; 41 | } => { 42 | if (typeof param === 'string') return { key: param, values: {} }; 43 | 44 | if (isRecord(param)) { 45 | const key = 46 | 'key' in param && typeof param.key === 'string' ? param.key : defaultKey; 47 | const values = 48 | 'values' in param && isRecord(param.values) ? param.values : {}; 49 | return { key, values }; 50 | } 51 | 52 | return { key: defaultKey, values: {} }; 53 | }; 54 | 55 | type ZodI18nMapOption = { 56 | t: ReturnType; 57 | tForm?: ReturnType; 58 | tCustom?: ReturnType; 59 | ns?: string | readonly string[]; 60 | }; 61 | 62 | type MakeZodI18nMap = (option: ZodI18nMapOption) => ZodErrorMap; 63 | 64 | export const makeZodI18nMap: MakeZodI18nMap = (option) => (issue, ctx) => { 65 | const { t, tForm, tCustom } = { 66 | ...option, 67 | }; 68 | 69 | let message: string; 70 | message = defaultErrorMap(issue, ctx).message; 71 | 72 | const path = issue.path.length > 0 && !!tForm 73 | ? { path: tForm(issue.path.join('.') as any) } 74 | : {}; 75 | 76 | switch (issue.code) { 77 | case ZodIssueCode.invalid_type: 78 | if (issue.received === ZodParsedType.undefined) { 79 | message = t('errors.invalid_type_received_undefined', { 80 | ...path, 81 | }); 82 | } else { 83 | message = t('errors.invalid_type', { 84 | expected: t(`types.${issue.expected}`), 85 | received: t(`types.${issue.received}`), 86 | ...path, 87 | }); 88 | } 89 | break; 90 | case ZodIssueCode.invalid_literal: 91 | message = t('errors.invalid_literal', { 92 | expected: JSON.stringify(issue.expected, jsonStringifyReplacer), 93 | ...path, 94 | }); 95 | break; 96 | case ZodIssueCode.unrecognized_keys: 97 | message = t('errors.unrecognized_keys', { 98 | keys: joinValues(issue.keys, ', '), 99 | count: issue.keys.length, 100 | ...path, 101 | }); 102 | break; 103 | case ZodIssueCode.invalid_union: 104 | message = t('errors.invalid_union', { 105 | ...path, 106 | }); 107 | break; 108 | case ZodIssueCode.invalid_union_discriminator: 109 | message = t('errors.invalid_union_discriminator', { 110 | options: joinValues(issue.options), 111 | ...path, 112 | }); 113 | break; 114 | case ZodIssueCode.invalid_enum_value: 115 | message = t('errors.invalid_enum_value', { 116 | options: joinValues(issue.options), 117 | received: issue.received, 118 | ...path, 119 | }); 120 | break; 121 | case ZodIssueCode.invalid_arguments: 122 | message = t('errors.invalid_arguments', { 123 | ...path, 124 | }); 125 | break; 126 | case ZodIssueCode.invalid_return_type: 127 | message = t('errors.invalid_return_type', { 128 | ...path, 129 | }); 130 | break; 131 | case ZodIssueCode.invalid_date: 132 | message = t('errors.invalid_date', { 133 | ...path, 134 | }); 135 | break; 136 | case ZodIssueCode.invalid_string: 137 | if (typeof issue.validation === 'object') { 138 | if ('startsWith' in issue.validation) { 139 | message = t('errors.invalid_string.startsWith', { 140 | startsWith: issue.validation.startsWith, 141 | ...path, 142 | }); 143 | } else if ('endsWith' in issue.validation) { 144 | message = t('errors.invalid_string.endsWith', { 145 | endsWith: issue.validation.endsWith, 146 | ...path, 147 | }); 148 | } 149 | } else { 150 | message = t(`errors.invalid_string.${issue.validation}`, { 151 | validation: t(`validations.${issue.validation}`), 152 | ...path, 153 | }); 154 | } 155 | break; 156 | case ZodIssueCode.too_small: { 157 | const minimum = 158 | issue.type === 'date' 159 | ? new Date(issue.minimum as number) 160 | : issue.minimum as number; 161 | message = t( 162 | `errors.too_small.${issue.type}.${ 163 | // eslint-disable-next-line no-nested-ternary 164 | issue.exact 165 | ? 'exact' 166 | : issue.inclusive 167 | ? 'inclusive' 168 | : 'not_inclusive' 169 | }`, 170 | { 171 | minimum, 172 | count: typeof minimum === 'number' ? minimum : undefined, 173 | ...path, 174 | }, 175 | ); 176 | break; 177 | } 178 | case ZodIssueCode.too_big: { 179 | const maximum = 180 | issue.type === 'date' 181 | ? new Date(issue.maximum as number) 182 | : issue.maximum as number; 183 | message = t( 184 | `errors.too_big.${issue.type}.${ 185 | // eslint-disable-next-line no-nested-ternary 186 | issue.exact 187 | ? 'exact' 188 | : issue.inclusive 189 | ? 'inclusive' 190 | : 'not_inclusive' 191 | }`, 192 | { 193 | maximum, 194 | count: typeof maximum === 'number' ? maximum : undefined, 195 | ...path, 196 | }, 197 | ); 198 | break; 199 | } 200 | case ZodIssueCode.custom: { 201 | const { key, values } = getKeyAndValues( 202 | issue.params?.i18n, 203 | 'errors.custom', 204 | ); 205 | 206 | message = (tCustom || t)(key as Parameters[0], { 207 | ...values, 208 | ...path, 209 | }); 210 | break; 211 | } 212 | case ZodIssueCode.invalid_intersection_types: 213 | message = t('errors.invalid_intersection_types', { 214 | ...path, 215 | }); 216 | break; 217 | case ZodIssueCode.not_multiple_of: 218 | message = t('errors.not_multiple_of', { 219 | multipleOf: issue.multipleOf as number, 220 | ...path, 221 | }); 222 | break; 223 | case ZodIssueCode.not_finite: 224 | message = t('errors.not_finite', { 225 | ...path, 226 | }); 227 | break; 228 | default: 229 | } 230 | 231 | return { message }; 232 | }; 233 | --------------------------------------------------------------------------------