Skip to content

Instantly share code, notes, and snippets.

@lucas-barake
Last active November 19, 2025 17:51
Show Gist options
  • Select an option

  • Save lucas-barake/92e6ff57c5f70a1a3d5a490f338309b0 to your computer and use it in GitHub Desktop.

Select an option

Save lucas-barake/92e6ff57c5f70a1a3d5a490f338309b0 to your computer and use it in GitHub Desktop.
i18n with Zod Example

Input schema:

import { z } from "zod";
import { DateTime } from "luxon";
import { type TranslationValues } from "next-intl";

export const createDebtRecurrentOptions = ["WEEKLY", "BIWEEKLY", "MONTHLY"] as const;
export const createDebtTypeOptions = ["SINGLE", "RECURRENT"] as const;

export const CURRENCIES = [
  "COP",
  "USD",
  "MXN",
  "EUR",
  "UYU",
  "ARS",
  "CLP",
  "BRL",
  "PYG",
  "PEN",
  "GBP",
] as const;
export type Currency = (typeof CURRENCIES)[number];
export const DEBT_MAX_BORROWERS = 4;
export const DEBT_MAX_WEEKLY_DURATION = 8;
export const DEBT_MAX_BIWEEKLY_DURATION = 6;
export const DEBT_MAX_MONTHLY_DURATION = 12;

type Messages = keyof IntlMessages["general-info-input"];
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export function getGeneralInfoInput(
  t?: (key: Messages, object?: TranslationValues | undefined) => string
) {
  return z
    .object({
      name: z
        .string({
          invalid_type_error: t?.("name-invalid-type"),
          required_error: t?.("name-required"),
        })
        .trim()
        .min(1, {
          message: t?.("name-required"),
        })
        .max(50, {
          message: t?.("name-max"),
        }),
      description: z
        .string({
          invalid_type_error: t?.("description-invalid-type"),
        })
        .trim()
        .max(100, {
          message: t?.("description-max"),
        })
        .nullable()
        .transform((value) => (value === "" ? null : value)),
      amount: z
        .number({
          invalid_type_error: t?.("amount-required"),
          required_error: t?.("amount-required"),
        })
        .gte(1, {
          message: t?.("amount-gte"),
        })
        .lte(1_000_000_000, {
          message: t?.("amount-lte"),
        })
        .multipleOf(0.01, {
          message: t?.("amount-multiple"),
        }),
      currency: z.enum(CURRENCIES),
      dueDate: z
        .string()
        .refine((iso) => DateTime.fromISO(iso).isValid, t?.("due-date-invalid"))
        .transform((iso) => DateTime.fromISO(iso).toUTC().toISO())
        .refine((iso) => {
          if (iso === null) return true;
          const now = DateTime.now().toUTC();
          const dueDate = DateTime.fromISO(iso).toUTC();
          return dueDate > now;
        }, t?.("due-date-future"))
        .nullable(),
      type: z.enum(createDebtTypeOptions),
      recurrency: z
        .object({
          frequency: z.enum(createDebtRecurrentOptions, {
            required_error: t?.("recurrency-frequency-required"),
          }),
          duration: z
            .number({
              coerce: true,
              invalid_type_error: t?.("recurrency-duration-required"),
              required_error: t?.("recurrency-duration-required"),
            })
            .gte(2, {
              message: t?.("recurrency-duration-gte"),
            }),
        })
        .superRefine((arg, ctx) => {
          if (arg.frequency === "WEEKLY") {
            if (arg.duration > DEBT_MAX_WEEKLY_DURATION) {
              ctx.addIssue({
                code: z.ZodIssueCode.custom,
                message:
                  t?.("weekly-duration-max", { max: DEBT_MAX_WEEKLY_DURATION }) ??
                  `Weekly recurrence duration must be less than ${DEBT_MAX_WEEKLY_DURATION}`,
                path: ["recurrency", "duration"],
              });
            }
          }

          if (arg.frequency === "BIWEEKLY") {
            if (arg.duration > DEBT_MAX_BIWEEKLY_DURATION) {
              ctx.addIssue({
                code: z.ZodIssueCode.custom,
                message:
                  t?.("biweekly-duration-max", { max: DEBT_MAX_BIWEEKLY_DURATION }) ??
                  `Biweekly recurrence duration must be less than ${DEBT_MAX_BIWEEKLY_DURATION}`,
                path: ["recurrency", "duration"],
              });
            }
          }

          if (arg.frequency === "MONTHLY") {
            if (arg.duration > DEBT_MAX_MONTHLY_DURATION) {
              ctx.addIssue({
                code: z.ZodIssueCode.custom,
                message:
                  t?.("monthly-duration-max", { max: DEBT_MAX_MONTHLY_DURATION }) ??
                  `Monthly recurrence duration must be less than ${DEBT_MAX_MONTHLY_DURATION}`,
                path: ["recurrency", "duration"],
              });
            }
          }
        })
        .nullable(),
    })
    .superRefine((arg, ctx) => {
      if (arg.type === "RECURRENT" && arg.recurrency === null) {
        ctx.addIssue({
          code: z.ZodIssueCode.custom,
          message: t?.("recurrency-required"),
          path: ["recurrency"],
        });
      }
    });
}
export type GeneralInfoInput = z.infer<ReturnType<typeof getGeneralInfoInput>>;

i18n File Example

{
  "general-info-input": {
    "name-required": "Name is required",
    "name-invalid-type": "Name must be a string",
    "name-max": "Name must be less than 50 characters",

    "description-max": "Description must be less than 100 characters",
    "description-invalid-type": "Description must be a string",

    "amount-gte": "Amount must be greater than 0",
    "amount-lte": "Amount must be less than or equal to 1,000,000,000",
    "amount-multiple": "Amount must be a multiple of 0.01",
    "amount-required": "Amount is required",

    "due-date-invalid": "Invalid date",
    "due-date-future": "Due date must be in the future",

    "recurrency-frequency-required": "You must select a recurrence frequency",
    "recurrency-duration-gte": "Duration must be greater than 1",
    "recurrency-duration-required": "You must add a recurrence duration",

    "weekly-duration-max": "Duration must be less than {max}",
    "biweekly-duration-max": "Duration must be less than {max}",
    "monthly-duration-max": "Duration must be less than {max}",

    "recurrency-required": "You must add a recurrence"
  }
}

Usage

  const t = useTranslations("debt-info-form");
  const formT = useTranslations("general-info-input");
  const [openCyclesInfo, setOpenCyclesInfo] = React.useState(false);

  const form = useForm<GeneralInfoInput>({
    defaultValues: formData.generalInfo,
    mode: "onSubmit",
    reValidateMode: "onChange",
    resolver: zodResolver(getGeneralInfoInput(formT)),
  });

The idea is to have two useTranslations. One for your actual content (in this case const t = useTranslations("debt-info-form")), and the other formT for the translations you will pass to the factory function.

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