Skip to content

Instantly share code, notes, and snippets.

@VIEWVIEWVIEW
Created September 30, 2025 22:04
Show Gist options
  • Select an option

  • Save VIEWVIEWVIEW/8c8730271406a803a83598e3cbe0b5a1 to your computer and use it in GitHub Desktop.

Select an option

Save VIEWVIEWVIEW/8c8730271406a803a83598e3cbe0b5a1 to your computer and use it in GitHub Desktop.
server/plugins/zod-error-map.ts
/**
* 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