Last active
September 24, 2025 14:05
-
-
Save gesielrosa/2cfce2862d9c0b4cce4b6e8e1ad67a9c to your computer and use it in GitHub Desktop.
Zod → next-intl Error Map
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { TranslationValues, useTranslations } from 'next-intl'; | |
| import { ZodErrorMap, ZodIssueCode, ZodParsedType, defaultErrorMap } from 'zod'; | |
| const jsonStringifyReplacer = (_: string, value: any): any => { | |
| if (typeof value === 'bigint') { | |
| return value.toString(); | |
| } | |
| return value; | |
| }; | |
| function joinValues<T extends any[]>(array: T, separator = ' | '): string { | |
| return array | |
| .map((val) => (typeof val === 'string' ? `'${val}'` : val)) | |
| .join(separator); | |
| } | |
| const isRecord = (value: unknown): value is TranslationValues => { | |
| if (typeof value !== 'object' || value === null) return false; | |
| for (const key in value) { | |
| if (!Object.prototype.hasOwnProperty.call(value, key)) return false; | |
| } | |
| return true; | |
| }; | |
| const getKeyAndValues = ( | |
| param: unknown, | |
| defaultKey: string, | |
| ): { key: string; values: TranslationValues } => { | |
| if (typeof param === 'string') return { key: param, values: {} }; | |
| if (isRecord(param)) { | |
| const key = typeof param.key === 'string' ? param.key : defaultKey; | |
| const values = isRecord(param.values) ? param.values : {}; | |
| return { key, values }; | |
| } | |
| return { key: defaultKey, values: {} }; | |
| }; | |
| export type HandlePathOption = { | |
| keyPrefix?: string; | |
| }; | |
| export type ZodI18nMapOption = { | |
| t: ReturnType<typeof useTranslations<string>>; | |
| handlePath?: HandlePathOption | false; | |
| }; | |
| export const makeZodI18nMap = (options: ZodI18nMapOption): ZodErrorMap => { | |
| const { t, handlePath = { keyPrefix: false } } = options; | |
| const translate = ( | |
| key: string, | |
| values: TranslationValues = {}, | |
| defaultMsg?: string, | |
| ) => { | |
| const translated = t(key, values); | |
| return translated === key && defaultMsg ? defaultMsg : translated; | |
| }; | |
| return (issue, ctx) => { | |
| let message = defaultErrorMap(issue, ctx).message; | |
| const pathValues = | |
| issue.path.length > 0 && handlePath !== false | |
| ? { | |
| path: t( | |
| [handlePath.keyPrefix, issue.path.join('.')] | |
| .filter(Boolean) | |
| .join('.'), | |
| ), | |
| } | |
| : {}; | |
| switch (issue.code) { | |
| case ZodIssueCode.invalid_type: | |
| if (issue.received === ZodParsedType.undefined) { | |
| message = translate( | |
| 'errors.invalid_type_received_undefined', | |
| pathValues, | |
| message, | |
| ); | |
| } else if (issue.received === ZodParsedType.null) { | |
| message = translate( | |
| 'errors.invalid_type_received_null', | |
| pathValues, | |
| message, | |
| ); | |
| } else { | |
| message = translate( | |
| 'errors.invalid_type', | |
| { | |
| expected: t(`types.${issue.expected}`), | |
| received: t(`types.${issue.received}`), | |
| ...pathValues, | |
| }, | |
| message, | |
| ); | |
| } | |
| break; | |
| case ZodIssueCode.invalid_literal: | |
| message = translate( | |
| 'errors.invalid_literal', | |
| { | |
| expected: JSON.stringify(issue.expected, jsonStringifyReplacer), | |
| ...pathValues, | |
| }, | |
| message, | |
| ); | |
| break; | |
| case ZodIssueCode.unrecognized_keys: | |
| message = translate( | |
| 'errors.unrecognized_keys', | |
| { | |
| keys: joinValues(issue.keys, ', '), | |
| count: issue.keys.length, | |
| ...pathValues, | |
| }, | |
| message, | |
| ); | |
| break; | |
| case ZodIssueCode.invalid_union: | |
| message = translate('errors.invalid_union', pathValues, message); | |
| break; | |
| case ZodIssueCode.invalid_union_discriminator: | |
| message = translate( | |
| 'errors.invalid_union_discriminator', | |
| { | |
| options: joinValues(issue.options), | |
| ...pathValues, | |
| }, | |
| message, | |
| ); | |
| break; | |
| case ZodIssueCode.invalid_enum_value: | |
| message = translate( | |
| 'errors.invalid_enum_value', | |
| { | |
| options: joinValues(issue.options), | |
| received: issue.received, | |
| ...pathValues, | |
| }, | |
| message, | |
| ); | |
| break; | |
| case ZodIssueCode.invalid_arguments: | |
| message = translate('errors.invalid_arguments', pathValues, message); | |
| break; | |
| case ZodIssueCode.invalid_return_type: | |
| message = translate('errors.invalid_return_type', pathValues, message); | |
| break; | |
| case ZodIssueCode.invalid_date: | |
| message = translate('errors.invalid_date', pathValues, message); | |
| break; | |
| case ZodIssueCode.invalid_string: | |
| if (typeof issue.validation === 'object') { | |
| if ('startsWith' in issue.validation) { | |
| message = translate( | |
| 'errors.invalid_string.startsWith', | |
| { startsWith: issue.validation.startsWith, ...pathValues }, | |
| message, | |
| ); | |
| } else if ('endsWith' in issue.validation) { | |
| message = translate( | |
| 'errors.invalid_string.endsWith', | |
| { endsWith: issue.validation.endsWith, ...pathValues }, | |
| message, | |
| ); | |
| } | |
| } else { | |
| message = translate( | |
| `errors.invalid_string.${issue.validation}`, | |
| { | |
| validation: t(`validations.${issue.validation}`), | |
| ...pathValues, | |
| }, | |
| message, | |
| ); | |
| } | |
| break; | |
| case ZodIssueCode.too_small: { | |
| const minimum = | |
| issue.type === 'date' | |
| ? new Date(issue.minimum as number) | |
| : issue.minimum; | |
| message = translate( | |
| `errors.too_small.${issue.type}.${ | |
| issue.exact | |
| ? 'exact' | |
| : issue.inclusive | |
| ? 'inclusive' | |
| : 'not_inclusive' | |
| }`, | |
| { | |
| minimum: typeof minimum === 'bigint' ? Number(minimum) : minimum, | |
| count: typeof minimum === 'number' ? minimum : undefined, | |
| ...pathValues, | |
| }, | |
| message, | |
| ); | |
| break; | |
| } | |
| case ZodIssueCode.too_big: { | |
| const maximum = | |
| issue.type === 'date' | |
| ? new Date(issue.maximum as number) | |
| : issue.maximum; | |
| message = translate( | |
| `errors.too_big.${issue.type}.${ | |
| issue.exact | |
| ? 'exact' | |
| : issue.inclusive | |
| ? 'inclusive' | |
| : 'not_inclusive' | |
| }`, | |
| { | |
| maximum: typeof maximum === 'bigint' ? Number(maximum) : maximum, | |
| count: typeof maximum === 'number' ? maximum : undefined, | |
| ...pathValues, | |
| }, | |
| message, | |
| ); | |
| break; | |
| } | |
| case ZodIssueCode.custom: { | |
| const { key, values } = getKeyAndValues( | |
| issue.params?.i18n, | |
| 'errors.custom', | |
| ); | |
| message = translate(key, { ...values, ...pathValues }, message); | |
| break; | |
| } | |
| case ZodIssueCode.invalid_intersection_types: | |
| message = translate( | |
| 'errors.invalid_intersection_types', | |
| pathValues, | |
| message, | |
| ); | |
| break; | |
| case ZodIssueCode.not_multiple_of: | |
| message = translate( | |
| 'errors.not_multiple_of', | |
| { | |
| multipleOf: | |
| typeof issue.multipleOf === 'bigint' | |
| ? Number(issue.multipleOf) | |
| : issue.multipleOf, | |
| ...pathValues, | |
| }, | |
| message, | |
| ); | |
| break; | |
| case ZodIssueCode.not_finite: | |
| message = translate('errors.not_finite', pathValues, message); | |
| break; | |
| default: | |
| break; | |
| } | |
| return { message }; | |
| }; | |
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| { | |
| "zod": { | |
| "errors": { | |
| "invalid_type": "Expected {expected}, received {received}", | |
| "invalid_type_received_undefined": "Required", | |
| "invalid_type_received_null": "Required", | |
| "invalid_literal": "Invalid literal value, expected {expected}", | |
| "unrecognized_keys": "Unrecognized key(s) in object: {keys}", | |
| "invalid_union": "Invalid input", | |
| "invalid_union_discriminator": "Invalid discriminator value. Expected {options}", | |
| "invalid_enum_value": "Invalid enum value. Expected {options}, received '{received}'", | |
| "invalid_arguments": "Invalid function arguments", | |
| "invalid_return_type": "Invalid function return type", | |
| "invalid_date": "Invalid date", | |
| "custom": "Invalid input", | |
| "invalid_intersection_types": "Intersection results could not be merged", | |
| "not_multiple_of": "Number must be a multiple of {multipleOf}", | |
| "not_finite": "Number must be finite", | |
| "invalid_string": { | |
| "email": "Invalid {validation}", | |
| "url": "Invalid {validation}", | |
| "uuid": "Invalid {validation}", | |
| "cuid": "Invalid {validation}", | |
| "regex": "Invalid", | |
| "datetime": "Invalid {validation}", | |
| "startsWith": "Invalid input: must start with \"{startsWith}\"", | |
| "endsWith": "Invalid input: must end with \"{endsWith}\"" | |
| }, | |
| "too_small": { | |
| "array": { | |
| "exact": "Array must contain exactly {minimum} element(s)", | |
| "inclusive": "Array must contain at least {minimum} element(s)", | |
| "not_inclusive": "Array must contain more than {minimum} element(s)" | |
| }, | |
| "string": { | |
| "exact": "String must contain exactly {minimum} character(s)", | |
| "inclusive": "String must contain at least {minimum} character(s)", | |
| "not_inclusive": "String must contain over {minimum} character(s)" | |
| }, | |
| "number": { | |
| "exact": "Number must be exactly {minimum}", | |
| "inclusive": "Number must be greater than or equal to {minimum}", | |
| "not_inclusive": "Number must be greater than {minimum}" | |
| }, | |
| "set": { | |
| "exact": "Invalid input", | |
| "inclusive": "Invalid input", | |
| "not_inclusive": "Invalid input" | |
| }, | |
| "date": { | |
| "exact": "Date must be exactly {minimum, datetime}", | |
| "inclusive": "Date must be greater than or equal to {minimum, datetime}", | |
| "not_inclusive": "Date must be greater than {minimum, datetime}" | |
| } | |
| }, | |
| "too_big": { | |
| "array": { | |
| "exact": "Array must contain exactly {maximum} element(s)", | |
| "inclusive": "Array must contain at most {maximum} element(s)", | |
| "not_inclusive": "Array must contain less than {maximum} element(s)" | |
| }, | |
| "string": { | |
| "exact": "String must contain exactly {maximum} character(s)", | |
| "inclusive": "String must contain at most {maximum} character(s)", | |
| "not_inclusive": "String must contain under {maximum} character(s)" | |
| }, | |
| "number": { | |
| "exact": "Number must be exactly {maximum}", | |
| "inclusive": "Number must be less than or equal to {maximum}", | |
| "not_inclusive": "Number must be less than {maximum}" | |
| }, | |
| "set": { | |
| "exact": "Invalid input", | |
| "inclusive": "Invalid input", | |
| "not_inclusive": "Invalid input" | |
| }, | |
| "date": { | |
| "exact": "Date must be exactly {maximum, datetime}", | |
| "inclusive": "Date must be smaller than or equal to {maximum, datetime}", | |
| "not_inclusive": "Date must be smaller than {maximum, datetime}" | |
| } | |
| } | |
| }, | |
| "validations": { | |
| "email": "email", | |
| "url": "url", | |
| "uuid": "uuid", | |
| "cuid": "cuid", | |
| "regex": "regex", | |
| "datetime": "datetime" | |
| }, | |
| "types": { | |
| "function": "function", | |
| "number": "number", | |
| "string": "string", | |
| "nan": "nan", | |
| "integer": "integer", | |
| "float": "float", | |
| "boolean": "boolean", | |
| "date": "date", | |
| "bigint": "bigint", | |
| "undefined": "undefined", | |
| "symbol": "symbol", | |
| "null": "null", | |
| "array": "array", | |
| "object": "object", | |
| "unknown": "unknown", | |
| "promise": "promise", | |
| "void": "void", | |
| "never": "never", | |
| "map": "map", | |
| "set": "set" | |
| } | |
| } | |
| } |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Basic example:
Custom code:
{ "zod": { "custom": { "too_young": "You must be at least {minAge} years old" } } }