Skip to content

Instantly share code, notes, and snippets.

@gesielrosa
Last active September 24, 2025 14:05
Show Gist options
  • Select an option

  • Save gesielrosa/2cfce2862d9c0b4cce4b6e8e1ad67a9c to your computer and use it in GitHub Desktop.

Select an option

Save gesielrosa/2cfce2862d9c0b4cce4b6e8e1ad67a9c to your computer and use it in GitHub Desktop.
Zod → next-intl Error Map
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 };
};
};
{
"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"
}
}
}
@gesielrosa
Copy link
Author

gesielrosa commented Apr 24, 2025

Basic example:

const tZod = useTranslations('zod');
z.setErrorMap(makeZodI18nMap({ t: tZod }));

Custom code:

ctx.addIssue({
  code: z.ZodIssueCode.custom,
  params: {
    i18n: {
      key: 'custom.too_young',
      values: { minAge: 2 },
    },
  },
});
{
  "zod": {
    "custom": {
      "too_young": "You must be at least {minAge} years old"
    }
  }
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment