Skip to content

Instantly share code, notes, and snippets.

@dBianchii
Created November 13, 2024 12:40
Show Gist options
  • Save dBianchii/d61e95c8552fd79f2000cee67ef665af to your computer and use it in GitHub Desktop.
Save dBianchii/d61e95c8552fd79f2000cee67ef665af to your computer and use it in GitHub Desktop.
Automatic zod translations with with next-intl, tRPC, react-hook-form
//TODO: figure out how to make typed namespaces work. (Both with i18n-ally and next-intl/use-intl)
type TranslationKeys = never;
export type ServerSideT<S extends TranslationKeys = never> = Awaited<
ReturnType<typeof getTranslations<S>>
>;
export type ClientSideT<S extends TranslationKeys = never> = ReturnType<
typeof useTranslations<S>
>;
export type IsomorficT<S extends TranslationKeys = never> =
| ServerSideT<S>
| ClientSideT<S>;
/**
* This error map is a modified version of the one used by zod-i18n
* Checkout the original at: https://github.com/aiji42/zod-i18n
*/
import type { useTranslations } from "next-intl";
import type { ZodErrorMap } from "zod";
import { defaultErrorMap, ZodIssueCode, ZodParsedType } from "zod";
const jsonStringifyReplacer = (_: string, value: unknown): unknown => {
if (typeof value === "bigint") {
return value.toString();
}
return value;
};
function joinValues<T extends unknown[]>(array: T, separator = " | "): string {
return array
.map((val) => (typeof val === "string" ? `'${val}'` : val))
.join(separator);
}
const isRecord = (value: unknown): value is Record<string, unknown> => {
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,
): {
values: Record<string, unknown>;
key: string;
} => {
if (typeof param === "string") return { key: param, values: {} };
if (isRecord(param)) {
const key =
"key" in param && typeof param.key === "string" ? param.key : defaultKey;
const values =
"values" in param && isRecord(param.values) ? param.values : {};
return { key, values };
}
return { key: defaultKey, values: {} };
};
export const zodNs = "zod";
export const formNs = "zod.form";
export const customErrorsNs = "zod.customErrors";
interface ZodI18nMapOption {
t: ReturnType<typeof useTranslations<typeof zodNs>>;
tForm?: ReturnType<typeof useTranslations<typeof formNs>>;
tCustom?: ReturnType<typeof useTranslations<typeof customErrorsNs>>;
ns?: string | readonly string[];
}
type MakeZodI18nMap = (option: ZodI18nMapOption) => ZodErrorMap;
export const makeZodI18nMap: MakeZodI18nMap = (option) => (issue, ctx) => {
const { t, tForm, tCustom } = {
...option,
};
let message: string;
message = defaultErrorMap(issue, ctx).message;
const path =
issue.path.length > 0 && !!tForm
? // eslint-disable-next-line @typescript-eslint/no-explicit-any
{ 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,
});
} else {
message = t("errors.invalid_type", {
expected: t(`types.${issue.expected}`),
received: t(`types.${issue.received}`),
...path,
});
}
break;
case ZodIssueCode.invalid_literal:
message = t("errors.invalid_literal", {
expected: JSON.stringify(issue.expected, jsonStringifyReplacer),
...path,
});
break;
case ZodIssueCode.unrecognized_keys:
message = t("errors.unrecognized_keys", {
keys: joinValues(issue.keys, ", "),
count: issue.keys.length,
...path,
});
break;
case ZodIssueCode.invalid_union:
message = t("errors.invalid_union", {
...path,
});
break;
case ZodIssueCode.invalid_union_discriminator:
message = t("errors.invalid_union_discriminator", {
options: joinValues(issue.options),
...path,
});
break;
case ZodIssueCode.invalid_enum_value:
message = t("errors.invalid_enum_value", {
options: joinValues(issue.options),
received: issue.received,
...path,
});
break;
case ZodIssueCode.invalid_arguments:
message = t("errors.invalid_arguments", {
...path,
});
break;
case ZodIssueCode.invalid_return_type:
message = t("errors.invalid_return_type", {
...path,
});
break;
case ZodIssueCode.invalid_date:
message = t("errors.invalid_date", {
...path,
});
break;
case ZodIssueCode.invalid_string:
if (typeof issue.validation === "object") {
if ("startsWith" in issue.validation) {
message = t("errors.invalid_string.startsWith", {
startsWith: issue.validation.startsWith,
...path,
});
} else if ("endsWith" in issue.validation) {
message = t("errors.invalid_string.endsWith", {
endsWith: issue.validation.endsWith,
...path,
});
}
} else {
message = t(`errors.invalid_string.${issue.validation}`, {
validation: t(`validations.${issue.validation}`),
...path,
});
}
break;
case ZodIssueCode.too_small: {
const minimum =
issue.type === "date"
? new Date(issue.minimum as number)
: (issue.minimum as number);
message = t(
`errors.too_small.${issue.type}.${
issue.exact
? "exact"
: issue.inclusive
? "inclusive"
: "not_inclusive"
}`,
{
minimum,
count: typeof minimum === "number" ? minimum : undefined,
...path,
},
);
break;
}
case ZodIssueCode.too_big: {
const maximum =
issue.type === "date"
? new Date(issue.maximum as number)
: (issue.maximum as number);
message = t(
`errors.too_big.${issue.type}.${
issue.exact
? "exact"
: issue.inclusive
? "inclusive"
: "not_inclusive"
}`,
{
maximum,
count: typeof maximum === "number" ? maximum : undefined,
...path,
},
);
break;
}
case ZodIssueCode.custom: {
const { key, values } = getKeyAndValues(
issue.params?.i18n,
"errors.custom",
);
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
message = (tCustom || t)(key as Parameters<typeof t>[0], {
...values,
...path,
});
break;
}
case ZodIssueCode.invalid_intersection_types:
message = t("errors.invalid_intersection_types", {
...path,
});
break;
case ZodIssueCode.not_multiple_of:
message = t("errors.not_multiple_of", {
multipleOf: issue.multipleOf as number,
...path,
});
break;
case ZodIssueCode.not_finite:
message = t("errors.not_finite", {
...path,
});
break;
default:
}
return { message };
};
import type { z, ZodSchema } from "zod";
import { cookies } from "next/headers";
import { getTranslations } from "next-intl/server";
import type { locales } from "@kdx/locales";
import { defaultLocale } from "@kdx/locales";
import { createI18nZodErrors } from "@kdx/validators/useI18nZodErrors";
export const getLocaleBasedOnCookie = () =>
(cookies().get("NEXT_LOCALE")?.value ??
defaultLocale) as (typeof locales)[number];
type SchemaGetterFromT<S extends ZodSchema> = (
t: Awaited<ReturnType<typeof getTranslations>>,
) => S;
export const T =
<S extends ZodSchema>(schemaGetter: SchemaGetterFromT<S>) =>
async (input: unknown) => {
const locale = getLocaleBasedOnCookie();
const t = await getTranslations({ locale });
await createI18nZodErrors({ locale });
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return schemaGetter(t).parse(input) as z.infer<S>;
};
//Example of how to use it in react
//Ill just add my custom react hook form hook I use. You can use the default useForm too
import {
useForm as __useForm,
} from "react-hook-form";
import type { ZodType, ZodTypeDef } from "zod";
const useForm = <
TOut extends FieldValues,
TDef extends ZodTypeDef,
TIn extends FieldValues,
>(
props: Omit<UseFormProps<TIn>, "resolver"> & {
schema: ZodType<TOut, TDef, TIn>;
},
) => {
const form = __useForm<TIn, unknown, TOut>({
...props,
resolver: zodResolver(props.schema, undefined),
});
return form;
};
import { useTranslations } from "next-intl";
import { useI18nZodErrors } from "@kdx/validators/useI18nZodErrors";
function DoCheckoutDialogButton(){
const t = useTranslations();
const form = useForm({
schema: ZDoCheckoutForShiftInputSchema(t),
defaultValues: {
date: new Date(),
},
});
useI18nZodErrors(); //Need to call this guy here unfortunately. Maybe I could add this to layout and then I won't have to do it again? Need to try.
return <></> //just use react-hook-form normally.
}
import { T } from "../../../../utils/locales";
export const kodixCareRouter = {
doCheckoutForShift: protectedProcedure
.input(T(ZDoCheckoutForShiftInputSchema)) // <----- example of how to use the big "T".
.use(kodixCareInstalledMiddleware)
.mutation(doCheckoutForShiftHandler),
} satisfies TRPCRouterRecord;
import { useTranslations } from "next-intl";
import { getTranslations } from "next-intl/server";
import { z } from "zod";
// import { useTranslations as expo_useTranslations } from "use-intl";
import {
customErrorsNs,
formNs,
makeZodI18nMap,
zodNs,
} from "./make-zod-error-map";
export const useI18nZodErrors = () => {
const t = useTranslations(zodNs);
const tForm = useTranslations(formNs);
const tCustom = useTranslations(customErrorsNs);
z.setErrorMap(makeZodI18nMap({ t, tForm, tCustom }));
};
export const createI18nZodErrors = async ({ locale }: { locale: string }) => {
const t = await getTranslations({ locale, namespace: zodNs });
const tForm = await getTranslations({ locale, namespace: formNs });
const tCustom = await getTranslations({ locale, namespace: customErrorsNs });
z.setErrorMap(makeZodI18nMap({ t, tForm, tCustom }));
};
// export const expo_useI18nZodErrors = () => {
// const t = expo_useTranslations(zodNs);
// const tForm = expo_useTranslations(formNs);
// const tCustom = expo_useTranslations(customErrorsNs);
// z.setErrorMap(makeZodI18nMap({ t, tForm, tCustom }));
// };
import type { IsomorficT } from "@kdx/locales";
export const ZDoCheckoutForShiftInputSchema = (t: IsomorficT) =>
z.object({
notes: z.string().optional(),
date: z
.date()
.max(new Date(), {
message: t("validators.Checkout time cannot be in the future"),
})
.transform(adjustDateToMinute),
});
{
"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}",
"date": "Invalid {validation}",
"emoji": "Invalid {validation}",
"nanoid": "Invalid {validation}",
"cuid2": "Invalid {validation}",
"ulid": "Invalid {validation}",
"time": "Invalid {validation}",
"ip": "Invalid {validation}",
"base64": "Invalid {validation}",
"duration": "Invalid {validation}",
"startsWith": "Invalid input: must start with \"{startsWith}\"",
"endsWith": "Invalid input: must end with \"{endsWith}\""
},
"too_small": {
"array": {
"exact": "{path, select, missingTranslation {Array} other {{path}}} must contain exactly {minimum} element(s)",
"inclusive": "{path, select, missingTranslation {Array} other {{path}}} must contain at least {minimum} element(s)",
"not_inclusive": "{path, select, missingTranslation {Array} other {{path}}} must contain more than {minimum} element(s)"
},
"string": {
"exact": "{path, select, missingTranslation {String} other {{path}}} must contain exactly {minimum} character(s)",
"inclusive": "{path, select, missingTranslation {String} other {{path}}} must contain at least {minimum} character(s)",
"not_inclusive": "{path, select, missingTranslation {String} other {{path}}} must contain over {minimum} character(s)"
},
"number": {
"exact": "{path, select, missingTranslation {Number} other {{path}}} must be exactly {minimum}",
"inclusive": "{path, select, missingTranslation {Number} other {{path}}} must be greater than or equal to {minimum}",
"not_inclusive": "{path, select, missingTranslation {Number} other {{path}}} must be greater than {minimum}"
},
"bigint": {
"exact": "{path, select, missingTranslation {Number} other {{path}}} must be exactly {minimum}",
"inclusive": "{path, select, missingTranslation {Number} other {{path}}} must be greater than or equal to {minimum}",
"not_inclusive": "{path, select, missingTranslation {Number} other {{path}}} must be greater than {minimum}"
},
"set": {
"exact": "Invalid input",
"inclusive": "Invalid input",
"not_inclusive": "Invalid input"
},
"date": {
"exact": "Date must be exactly {minimum, date, short}",
"inclusive": "Date must be greater than or equal to {minimum, date, short}",
"not_inclusive": "Date must be greater than {minimum, date, short}"
}
},
"too_big": {
"array": {
"exact": "{path, select, missingTranslation {Array} other {{path}}} must contain exactly {maximum} element(s)",
"inclusive": "{path, select, missingTranslation {Array} other {{path}}} must contain at most {maximum} element(s)",
"not_inclusive": "{path, select, missingTranslation {Array} other {{path}}} must contain less than {maximum} element(s)"
},
"string": {
"exact": "{path, select, missingTranslation {String} other {{path}}} must contain exactly {maximum} character(s)",
"inclusive": "{path, select, missingTranslation {String} other {{path}}} must contain at most {maximum} character(s)",
"not_inclusive": "{path, select, missingTranslation {String} other {{path}}} must contain under {maximum} character(s)"
},
"number": {
"exact": "{path, select, missingTranslation {Number} other {{path}}} must be exactly {maximum}",
"inclusive": "{path, select, missingTranslation {Number} other {{path}}} must be less than or equal to {maximum}",
"not_inclusive": "{path, select, missingTranslation {Number} other {{path}}} must be less than {maximum}"
},
"bigint": {
"exact": "{path, select, missingTranslation {Number} other {{path}}} must be exactly {maximum}",
"inclusive": "{path, select, missingTranslation {Number} other {{path}}} must be less than or equal to {maximum}",
"not_inclusive": "{path, select, missingTranslation {Number} other {{path}}} must be less than {maximum}"
},
"set": {
"exact": "Invalid input",
"inclusive": "Invalid input",
"not_inclusive": "Invalid input"
},
"date": {
"exact": "Date must be exactly {maximum, date, short}",
"inclusive": "Date must be smaller than or equal to {maximum, date, short}",
"not_inclusive": "Date must be smaller than {maximum, date, short}"
}
}
},
"validations": {
"email": "email",
"url": "url",
"uuid": "uuid",
"cuid": "cuid",
"regex": "regex",
"datetime": "datetime",
"date": "date",
"emoji": "emoji",
"nanoid": "nanoid",
"cuid2": "cuid2",
"ulid": "ulid",
"time": "time",
"ip": "ip",
"base64": "base64",
"duration": "duration"
},
"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"
},
"customErrors": {
"admin_username_error": "Nice try!"
},
"form": {
"teamName": "Team name"
}
}
}
{
"zod": {
"errors": {
"invalid_type": "O dado deve ser do tipo {expected}, porém foi enviado {received}",
"invalid_type_received_undefined": "Obrigatório",
"invalid_type_received_null": "Obrigatório",
"invalid_literal": "Valor literal inválido, era esperado {expected}",
"unrecognized_keys": "Chave(s) não reconhecida(s) no objeto: {keys}",
"invalid_union": "Entrada inválida",
"invalid_union_discriminator": "Valor discriminador inválido. Foi esperado {options}",
"invalid_enum_value": "Enum no formato inválido. Foi esperado {options}, porém foi recebido '{received}'",
"invalid_arguments": "Argumento de função inválido",
"invalid_return_type": "Tipo de retorno de função inválido",
"invalid_date": "Data inválida",
"custom": "Entrada inválida",
"invalid_intersection_types": "Valores de interseção não poderam ser mesclados",
"not_multiple_of": "O número deverá ser múltiplo de {multipleOf}",
"not_finite": "Número não pode ser infinito",
"invalid_string": {
"email": "E-mail inválido",
"url": "URL inválida",
"uuid": "UUID inválido",
"cuid": "CUID inválido",
"regex": "Combinação inválida",
"datetime": "datetime inválido",
"date": "Data inválida",
"emoji": "Emoji inválido",
"nanoid": "Nanoid inválido",
"cuid2": "CUID2 inválido",
"ulid": "ULID inválido",
"time": "Hora inválida",
"ip": "IP inválido",
"base64": "Base64 inválido",
"duration": "Duração inválida",
"startsWith": "Entrada inválida: deve iniciar com \"{startsWith}\"",
"endsWith": "Entrada inválida: deve terminar com \"{endsWith}\""
},
"too_small": {
"array": {
"exact": "{path, select, missingTranslation {Array} other {{path}}} deve conter exatamente {minimum} elemento(s)",
"inclusive": "{path, select, missingTranslation {Array} other {{path}}} deve conter pelo menos {minimum} elemento(s)",
"not_inclusive": "{path, select, missingTranslation {Array} other {{path}}} deve conter mais que {minimum} elemento(s)"
},
"string": {
"exact": "{path, select, missingTranslation {String} other {{path}}} deve conter exatamente {minimum} character(es)",
"inclusive": "{path, select, missingTranslation {String} other {{path}}} deve conter pelo menos {minimum} character(es)",
"not_inclusive": "{path, select, missingTranslation {String} other {{path}}} deve conter mais que {minimum} character(es)"
},
"number": {
"exact": "{path, select, missingTranslation {Number} other {{path}}} deve ser exatamenter {minimum}",
"inclusive": "{path, select, missingTranslation {Number} other {{path}}} deve ser maior ou igual que {minimum}",
"not_inclusive": "{path, select, missingTranslation {Number} other {{path}}} deve ser maior que {minimum}"
},
"bigint": {
"exact": "{path, select, missingTranslation {Number} other {{path}}} deve ser exatamente {minimum}",
"inclusive": "{path, select, missingTranslation {Number} other {{path}}} deve ser maior ou igual que {minimum}",
"not_inclusive": "{path, select, missingTranslation {Number} other {{path}}} deve ser maior que {minimum}"
},
"set": {
"exact": "Entrada inválida",
"inclusive": "Entrada inválida",
"not_inclusive": "Entrada inválida"
},
"date": {
"exact": "Data deve ser exatamente {minimum, date, short}",
"inclusive": "Data deve ser maior ou igual a {minimum, date, short}",
"not_inclusive": "Data deve ser maior que {minimum, date, short}"
}
},
"too_big": {
"array": {
"exact": "{path, select, missingTranslation {Array} other {{path}}} deve conter exatamente {maximum} elemento(s)",
"inclusive": "{path, select, missingTranslation {Array} other {{path}}} deve conter no máximo {maximum} elemento(s)",
"not_inclusive": "{path, select, missingTranslation {Array} other {{path}}} deve conter menos que {maximum} elemento(s)"
},
"string": {
"exact": "{path, select, missingTranslation {String} other {{path}}} deve conter exatamente {maximum} caracter(es)",
"inclusive": "{path, select, missingTranslation {String} other {{path}}} deve conter no máximo {maximum} caracter(es)",
"not_inclusive": "{path, select, missingTranslation {String} other {{path}}} deve conter menos que {maximum} caracter(es)"
},
"number": {
"exact": "{path, select, missingTranslation {Number} other {{path}}} deve ser exatamente {maximum}",
"inclusive": "{path, select, missingTranslation {Number} other {{path}}} deve ser menor ou igual a {maximum}",
"not_inclusive": "{path, select, missingTranslation {Number} other {{path}}} deve ser menor que {maximum}"
},
"bigint": {
"exact": "{path, select, missingTranslation {Number} other {{path}}} deve ser exatamente {maximum}",
"inclusive": "{path, select, missingTranslation {Number} other {{path}}} deve ser menor ou igual a {maximum}",
"not_inclusive": "{path, select, missingTranslation {Number} other {{path}}} deve ser menor que {maximum}"
},
"set": {
"exact": "Entrada inválida",
"inclusive": "Entrada inválida",
"not_inclusive": "Entrada inválida"
},
"date": {
"exact": "Data deve ser exatamente {maximum, date, short}",
"inclusive": "Data deve ser menor ou igual que {maximum, date, short}",
"not_inclusive": "Data deve ser menor que {maximum, date, short}"
}
}
},
"validations": {
"email": "email",
"url": "url",
"uuid": "uuid",
"cuid": "cuid",
"regex": "regex",
"datetime": "datetime",
"date": "date",
"emoji": "emoji",
"nanoid": "nanoid",
"cuid2": "cuid2",
"ulid": "ulid",
"time": "time",
"ip": "ip",
"base64": "base64",
"duration": "duration"
},
"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"
},
"customErrors": {
"admin_username_error": "Boa irmao!"
},
"form": {
"teamName": "Nome da equipe"
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment