-
-
Notifications
You must be signed in to change notification settings - Fork 1.7k
Open
Description
Feature Request: Typed Error Codes for i18n Support
Problem Statement
While Zod's built-in validators have error codes (e.g., too_small, too_big, invalid_type), these codes are:
- Not typed - no TypeScript union type of possible error codes for a schema
- Inconsistent with
.refine()- custom validations using.refine()only get a generic"custom"code - Not customizable - you can't define semantic error codes for business logic validation
This creates challenges for internationalization (i18n) because:
- Error messages must be hardcoded in a single language when the schema is defined
- For built-in errors, you must map untyped string codes (
"too_small","too_big") to translation keys - For
.refine()errors, you must parse error messages or use the generic"custom"code - There's no way to get type-safe autocomplete for all possible error codes in a schema
Proposed Solution
Add support for typed, custom error codes using string unions or enums, allowing developers to specify semantic error identifiers that can be mapped to localized messages at runtime.
Current State: .refine() limitations
const priceOptionsSchema = z
.array(z.number())
.min(1) // ✅ Has code: "too_small" (but not typed)
.refine((arr) => arr.length === new Set(arr).size, {
message: "Price options must be unique"
// ❌ No way to specify custom code - defaults to "custom"
});
// Current error handling
const result = priceOptionsSchema.safeParse([1, 1, 2]);
if (!result.success) {
result.error.issues.forEach(issue => {
// issue.code is "custom" for refine - not helpful for i18n!
// Must parse issue.message or check issue.path to determine what went wrong
console.log(issue.code); // "custom"
});
}Proposed API: Custom error codes with type inference
const priceOptionsSchema = z
.array(z.number())
.min(1, {
message: "Price options must be at least 1",
code: "PRICE_OPTIONS_MIN_LENGTH" as const // Custom semantic code
})
.refine((arr) => arr.length === new Set(arr).size, {
message: "Price options must be unique",
code: "PRICE_OPTIONS_NOT_UNIQUE" as const // ✅ Now refine has a custom code!
});
// Error handling with type-safe codes
const result = priceOptionsSchema.safeParse([1, 1, 2]);
if (!result.success) {
result.error.issues.forEach(issue => {
// issue.code is now "PRICE_OPTIONS_NOT_UNIQUE" - perfect for i18n!
// TypeScript knows: issue.code is "PRICE_OPTIONS_MIN_LENGTH" | "PRICE_OPTIONS_NOT_UNIQUE"
const translatedMessage = t(`validation.${issue.code}`);
});
}API Design Option 2: Custom Error Code Type
enum PriceErrorCodes {
MIN_LENGTH = "PRICE_OPTIONS_MIN_LENGTH",
NOT_UNIQUE = "PRICE_OPTIONS_NOT_UNIQUE"
}
const priceOptionsSchema = z
.array(z.number())
.min(1, {
message: "Price options must be at least 1",
code: PriceErrorCodes.MIN_LENGTH
})
.refine((arr) => arr.length === new Set(arr).size, {
message: "Price options must be unique",
code: PriceErrorCodes.NOT_UNIQUE
});
type PriceOptionsError = z.infer<typeof priceOptionsSchema.errorCode>;
// Type: PriceErrorCodesWhy This Matters: The .refine() Problem
.refine() is commonly used for business logic validation that can't be expressed with built-in validators:
// Common .refine() use cases that need custom error codes:
z.array(z.number())
.refine(arr => arr.length === new Set(arr).size) // Uniqueness
z.object({ password: z.string(), confirm: z.string() })
.refine(data => data.password === data.confirm) // Cross-field validation
z.string()
.refine(async email => !(await isEmailTaken(email))) // Async validationCurrently, all of these get code: "custom", making i18n impossible without fragile workarounds.
Benefits
- Type Safety: Error codes are typed and autocomplete-friendly
- i18n Support: Frontend can map error codes to localized messages without parsing
- Solves
.refine()limitations: Custom validations get meaningful error codes - Separation of Concerns: Backend validates with codes, frontend handles presentation
- Backward Compatible: Custom error codes would be optional, existing code continues to work
- Better DX: Developers can define semantic error identifiers alongside validation logic
Use Case: Full-Stack i18n Application
Backend (Node.js API)
// Define validation with error codes
export const createUserSchema = z.object({
username: z.string().min(1, {
message: "Username required",
code: "USERNAME_REQUIRED"
}),
email: z.string().email({
code: "EMAIL_INVALID"
}),
password: z.string().min(8, {
code: "PASSWORD_TOO_SHORT"
}),
confirmPassword: z.string()
}).refine(data => data.password === data.confirmPassword, {
message: "Passwords must match",
code: "PASSWORDS_DONT_MATCH"
});
// Validation in API endpoint
app.post('/api/users', async (req, res) => {
const result = createUserSchema.safeParse(req.body);
if (!result.success) {
// Return error codes to frontend
return res.status(400).json({
errors: result.error.issues.map(issue => ({
field: issue.path.join('.'),
code: issue.code
}))
});
}
// ... create user
});Frontend (React/Vue/etc)
// Translation files
// en.json
{
"validation": {
"USERNAME_REQUIRED": "Username is required",
"EMAIL_INVALID": "Please enter a valid email address",
"PASSWORD_TOO_SHORT": "Password must be at least 8 characters",
"PASSWORDS_DONT_MATCH": "Passwords do not match"
}
}
// de.json
{
"validation": {
"USERNAME_REQUIRED": "Benutzername ist erforderlich",
"EMAIL_INVALID": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
"PASSWORD_TOO_SHORT": "Passwort muss mindestens 8 Zeichen lang sein",
"PASSWORDS_DONT_MATCH": "Passwörter stimmen nicht überein"
}
}
// Error handling
try {
const response = await fetch('/api/users', {
method: 'POST',
body: JSON.stringify(userData)
});
if (!response.ok) {
const { errors } = await response.json();
errors.forEach(({ field, code }) => {
// Type-safe error code with autocomplete!
showFieldError(field, t(`validation.${code}`));
});
}
} catch (error) {
showErrorToast(t('validation.NETWORK_ERROR'));
}Current Workarounds (and their limitations)
For built-in validators:
// ❌ Untyped string literals - no autocomplete, typos possible
if (issue.code === "too_small") {
return t("validation.tooSmall");
}For .refine() validators:
// ❌ Option 1: Parse error message (fragile, language-dependent)
if (issue.message.includes("unique")) {
return t("validation.notUnique");
}
// ❌ Option 2: Check path (doesn't work for array/object validations)
if (issue.path.includes("prices")) {
return t("validation.pricesError");
}
// ❌ Option 3: Duplicate validation in frontend (defeats purpose of shared schemas)
// Backend: zod schema validation
// Frontend: separate validation functions with i18nOther limitations:
- Separate validation logic: Duplicates validation between frontend/backend
- English-only errors: Poor UX for international users
- Custom error classes: Verbose, loses Zod's ergonomics
Implementation Notes
- Error codes should be preserved through transformations
- TypeScript should infer union types of all possible error codes for a schema
- Consider adding a
.code()method similar to.message()for consistency - ZodIssue interface would need a
code?: stringproperty
Metadata
Metadata
Assignees
Labels
No labels