Created
September 30, 2025 22:04
-
-
Save VIEWVIEWVIEW/8c8730271406a803a83598e3cbe0b5a1 to your computer and use it in GitHub Desktop.
server/plugins/zod-error-map.ts
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 Error Map Plugin for Nuxt 4 + Nitro | |
| * | |
| * This plugin provides internationalized Zod validation error messages | |
| * by injecting a `getZodErrorMap()` function into `event.context`. | |
| * | |
| * Usage in API handlers: | |
| * const errorMap = await event.context.getZodErrorMap(); | |
| * schema.safeParse(data, { errorMap }); | |
| */ | |
| import type { H3Event } from "h3"; | |
| import { | |
| defaultErrorMap, | |
| type ZodErrorMap, | |
| ZodIssueCode, | |
| ZodParsedType, | |
| } from "zod"; | |
| import { useTranslation } from "#imports"; | |
| // TypeScript module augmentation to add getZodErrorMap to H3 event context | |
| declare module "h3" { | |
| interface H3EventContext { | |
| /** | |
| * Returns a cached, internationalized Zod error map for the current request. | |
| * The error map translates Zod validation errors based on the request's locale. | |
| */ | |
| getZodErrorMap: () => Promise<ZodErrorMap>; | |
| } | |
| } | |
| // ============================================================================ | |
| // Helper Functions | |
| // ============================================================================ | |
| /** | |
| * Joins an array of values into a string, wrapping strings in quotes. | |
| * Used for formatting enum options and array values in error messages. | |
| * | |
| * @example joinValues(['a', 'b', 1]) => "'a', 'b', 1" | |
| */ | |
| const joinValues = (array: unknown[], separator = ", ") => | |
| array | |
| .map((val) => (typeof val === "string" ? `'${val}'` : val)) | |
| .join(separator); | |
| /** | |
| * Custom JSON.stringify replacer that converts BigInt to string. | |
| * Prevents "TypeError: Do not know how to serialize a BigInt" errors. | |
| */ | |
| const jsonStringifyReplacer = (_: string, value: unknown) => | |
| typeof value === "bigint" ? value.toString() : value; | |
| /** | |
| * Type guard to check if a value is a plain object (not null or array). | |
| */ | |
| const isRecord = (v: unknown): v is Record<string, unknown> => | |
| typeof v === "object" && v !== null && !Array.isArray(v); | |
| /** | |
| * Extracts translation key and values from custom Zod error params. | |
| * | |
| * Supports two formats: | |
| * - String: `"custom.error.key"` → uses as-is with no interpolation values | |
| * - Object: `{ key: "custom.key", values: { name: "field.name" } }` → translates nested values | |
| * | |
| * @param param - The i18n parameter from Zod custom error | |
| * @param defaultKey - Fallback key if param is invalid | |
| * @param t - Translation function for nested value translation | |
| */ | |
| const getKeyAndValues = ( | |
| param: unknown, | |
| defaultKey: string, | |
| t: (key: string, values?: Record<string, unknown>) => string, | |
| ) => { | |
| // Simple string key | |
| if (typeof param === "string") | |
| return { key: param, values: {} as Record<string, unknown> }; | |
| // Complex object with key and values | |
| if (isRecord(param)) { | |
| const key = | |
| "key" in param && | |
| typeof (param as Record<string, unknown>).key === "string" | |
| ? ((param as Record<string, unknown>).key as string) | |
| : defaultKey; | |
| // Translate values recursively (values themselves can be i18n keys) | |
| const values = | |
| "values" in param && isRecord((param as Record<string, unknown>).values) | |
| ? Object.entries( | |
| (param as Record<string, unknown>).values as Record<string, string>, | |
| ).reduce( | |
| (acc, [k, v]) => { | |
| const next = acc as Record<string, unknown>; | |
| next[k] = t(v); | |
| return next; | |
| }, | |
| {} as Record<string, unknown>, | |
| ) | |
| : {}; | |
| return { key, values }; | |
| } | |
| // Fallback to default | |
| return { key: defaultKey, values: {} as Record<string, unknown> }; | |
| }; | |
| // ============================================================================ | |
| // Core Zod Error Map Creation | |
| // ============================================================================ | |
| /** | |
| * Creates a new Zod error map with internationalized messages. | |
| * | |
| * This function builds a custom error map that translates all Zod validation | |
| * errors using the current request's locale (via useTranslation). | |
| * | |
| * @param event - H3 event object containing request context | |
| * @returns A Zod error map function with translated messages | |
| */ | |
| async function createZodErrorMap(event: H3Event): Promise<ZodErrorMap> { | |
| const t = await useTranslation(event); | |
| return (error, ctx) => { | |
| let message = defaultErrorMap(error, ctx).message; | |
| switch (error.code) { | |
| case ZodIssueCode.invalid_type: | |
| if (error.received === ZodParsedType.undefined) { | |
| message = t("zodI18n.errors.invalid_type_received_undefined"); | |
| } else { | |
| message = t("zodI18n.errors.invalid_type", { | |
| expected: t(`zodI18n.types.${error.expected}`), | |
| received: t(`zodI18n.types.${error.received}`), | |
| }); | |
| } | |
| break; | |
| case ZodIssueCode.invalid_literal: | |
| message = t("zodI18n.errors.invalid_literal", { | |
| expected: JSON.stringify(error.expected, jsonStringifyReplacer), | |
| }); | |
| break; | |
| case ZodIssueCode.unrecognized_keys: | |
| message = t("zodI18n.errors.unrecognized_keys", { | |
| keys: joinValues(error.keys, ", "), | |
| }); | |
| break; | |
| case ZodIssueCode.invalid_union: | |
| message = t("zodI18n.errors.invalid_union"); | |
| break; | |
| case ZodIssueCode.invalid_union_discriminator: | |
| message = t("zodI18n.errors.invalid_union_discriminator", { | |
| options: joinValues(error.options), | |
| }); | |
| break; | |
| case ZodIssueCode.invalid_enum_value: | |
| message = t("zodI18n.errors.invalid_enum_value", { | |
| options: joinValues(error.options), | |
| received: error.received, | |
| }); | |
| break; | |
| case ZodIssueCode.invalid_arguments: | |
| message = t("zodI18n.errors.invalid_arguments"); | |
| break; | |
| case ZodIssueCode.invalid_return_type: | |
| message = t("zodI18n.errors.invalid_return_type"); | |
| break; | |
| case ZodIssueCode.invalid_date: | |
| message = t("zodI18n.errors.invalid_date"); | |
| break; | |
| case ZodIssueCode.invalid_string: | |
| if (typeof error.validation === "object") { | |
| if ("startsWith" in (error.validation as Record<string, unknown>)) { | |
| message = t("zodI18n.errors.invalid_string.startsWith", { | |
| startsWith: (error.validation as Record<string, unknown>) | |
| .startsWith as string, | |
| }); | |
| } else if ( | |
| "endsWith" in (error.validation as Record<string, unknown>) | |
| ) { | |
| message = t("zodI18n.errors.invalid_string.endsWith", { | |
| endsWith: (error.validation as Record<string, unknown>) | |
| .endsWith as string, | |
| }); | |
| } | |
| } else { | |
| message = t(`zodI18n.errors.invalid_string.${error.validation}`, { | |
| validation: t(`zodI18n.validations.${error.validation}`), | |
| }); | |
| } | |
| break; | |
| case ZodIssueCode.too_small: | |
| message = t( | |
| `zodI18n.errors.too_small.${error.type}.${error.exact ? "exact" : error.inclusive ? "inclusive" : "not_inclusive"}`, | |
| { | |
| minimum: | |
| error.type === "date" | |
| ? new Date(error.minimum as number).toISOString() | |
| : error.minimum, | |
| }, | |
| ); | |
| break; | |
| case ZodIssueCode.too_big: | |
| message = t( | |
| `zodI18n.errors.too_big.${error.type}.${error.exact ? "exact" : error.inclusive ? "inclusive" : "not_inclusive"}`, | |
| { | |
| maximum: | |
| error.type === "date" | |
| ? new Date(error.maximum as number).toISOString() | |
| : error.maximum, | |
| }, | |
| ); | |
| break; | |
| case ZodIssueCode.custom: { | |
| const { key, values } = getKeyAndValues( | |
| (error.params as Record<string, unknown> | undefined)?.i18n, | |
| "zodI18n.errors.custom", | |
| t, | |
| ); | |
| message = t(key, values); | |
| break; | |
| } | |
| case ZodIssueCode.invalid_intersection_types: | |
| message = t("zodI18n.errors.invalid_intersection_types"); | |
| break; | |
| case ZodIssueCode.not_multiple_of: | |
| message = t("zodI18n.errors.not_multiple_of", { | |
| multipleOf: error.multipleOf, | |
| }); | |
| break; | |
| case ZodIssueCode.not_finite: | |
| message = t("zodI18n.errors.not_finite"); | |
| break; | |
| default: | |
| break; | |
| } | |
| return { message }; | |
| }; | |
| } | |
| /** | |
| * Gets or creates a cached Zod error map for the current request. | |
| * | |
| * This function implements per-request cache by storing the error map | |
| * on `event.context.zodErrorMap`. The cache is automatically cleared between | |
| * requests since each request gets a fresh context object. | |
| * | |
| * @param event - H3 event object containing request context | |
| * @returns Cached or newly created Zod error map | |
| */ | |
| async function getZodErrorMap(event: H3Event): Promise<ZodErrorMap> { | |
| const ctx = (event as unknown as { context: Record<string, unknown> }) | |
| .context; | |
| if (ctx.zodErrorMap) return ctx.zodErrorMap as ZodErrorMap; | |
| const map = await createZodErrorMap(event); | |
| ctx.zodErrorMap = map as unknown as ZodErrorMap; | |
| return map; | |
| } | |
| export default defineNitroPlugin((nitroApp) => { | |
| nitroApp.hooks.hook("request", (event) => { | |
| // Inject lazy getter for ZodErrorMap into event.context | |
| // This reuses the existing caching in getZodErrorMap | |
| event.context.getZodErrorMap = async (): Promise<ZodErrorMap> => { | |
| return await getZodErrorMap(event); | |
| }; | |
| }); | |
| }); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment