Skip to content

[Feature Request] Typed error codes for i18n support #5387

@ThomasKientz

Description

@ThomasKientz

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:

  1. Not typed - no TypeScript union type of possible error codes for a schema
  2. Inconsistent with .refine() - custom validations using .refine() only get a generic "custom" code
  3. Not customizable - you can't define semantic error codes for business logic validation

This creates challenges for internationalization (i18n) because:

  1. Error messages must be hardcoded in a single language when the schema is defined
  2. For built-in errors, you must map untyped string codes ("too_small", "too_big") to translation keys
  3. For .refine() errors, you must parse error messages or use the generic "custom" code
  4. 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: PriceErrorCodes

Why 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 validation

Currently, all of these get code: "custom", making i18n impossible without fragile workarounds.

Benefits

  1. Type Safety: Error codes are typed and autocomplete-friendly
  2. i18n Support: Frontend can map error codes to localized messages without parsing
  3. Solves .refine() limitations: Custom validations get meaningful error codes
  4. Separation of Concerns: Backend validates with codes, frontend handles presentation
  5. Backward Compatible: Custom error codes would be optional, existing code continues to work
  6. 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 i18n

Other limitations:

  1. Separate validation logic: Duplicates validation between frontend/backend
  2. English-only errors: Poor UX for international users
  3. 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?: string property

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions