错误消息规范化

要点

  • Zod 默认的错误消息是英文的,格式也不统一——同一类校验失败可能有多种 message 写法
  • 面向前端的 API 需要一份稳定的错误响应结构,让前端不用猜「这个接口返回的字段叫什么」
  • 错误码(VALIDATION_ERRORFORMAT_ERROR 等)让前端按码分发处理逻辑,比解析 message 文本可靠
  • 字段级错误映射把嵌套对象的校验错误按路径分组,前端可以精确标注到对应表单字段
  • 自定义错误消息(.message 参数)和 i18n 是两条路径,不要混在一起做
  • 错误消息暴露过多内部细节会增加攻击面,需要按环境裁剪

1. 默认错误消息的问题

01 里介绍过 zValidator 的用法,也看到了默认的错误格式。Zod 校验失败时,result.error 的结构大致如下:

// error-structure.ts
{
  issues: [
    {
      code: 'too_small',
      minimum: 1,
      message: 'String must contain at least 1 character(s)',
      path: ['name']
    },
    {
      code: 'invalid_string',
      validation: 'email',
      message: 'Invalid email',
      path: ['email']
    }
  ]
}

这份数据对机器来说信息完整,但对前端和终端用户有三个问题:

  1. message 是英文——产品面向中文用户时不能直接展示
  2. issues 是扁平数组——嵌套对象(address.street)的路径信息混在一起,前端需要自己按 path 分组
  3. code 和 validation 是 Zod 内部概念——too_smallinvalid_string 对前端没有业务含义

直接透传这些数据给前端,等于把校验库的内部实现暴露给了客户端。

2. 设计统一的错误响应格式

在写代码之前,先确定一份稳定的响应结构。这是前端、后端、移动端共同遵守的约定:

// types/api-error.ts
interface ApiError {
  /** 错误码,前端据此决定处理策略 */
  code: string
  /** 人类可读的描述 */
  message: string
  /** 字段级错误明细,仅校验类错误有值 */
  details?: Record<string, string[]>
  /** 请求追踪 ID,便于关联日志 */
  requestId?: string
}

几个考量:code 用字符串常量(如 'VALIDATION_ERROR')而不是数字,避免和 HTTP status code 混淆;message 面向开发者,前端根据 codedetails 组织用户展示;details 的 key 是字段路径,value 是该字段的所有错误数组。

错误码体系

错误码的目标是让前端按「处理策略」分支,而不是按「出错原因」穷举:

// constants/error-codes.ts
const ErrorCodes = {
  VALIDATION_ERROR: 'VALIDATION_ERROR',  // 兜底大类
  FORMAT_ERROR: 'FORMAT_ERROR',          // 字段格式不正确
  TYPE_ERROR: 'TYPE_ERROR',              // 字段类型不匹配
  REQUIRED_ERROR: 'REQUIRED_ERROR',      // 缺少必填字段
  RANGE_ERROR: 'RANGE_ERROR',            // 值超出范围
} as const

VALIDATION_ERROR 是兜底大类,前端先按大类走表单标注逻辑,再按细分码做差异化展示。错误码不需要一开始就设计得很全——先列最常见的三到五种,后续按需补充。

3. 构建可复用的错误格式化工具

把 Zod 的 ZodError 转换成统一格式,核心逻辑是遍历 issues、按路径分组:

// src/utils/validation-error.ts
import type { ZodError, ZodIssue } from 'zod'
 
export interface ApiValidationError {
  code: 'VALIDATION_ERROR'
  message: string
  details: Record<string, string[]>
}
 
function issueToMessage(issue: ZodIssue): string {
  switch (issue.code) {
    case 'invalid_type':
      return `期望类型 ${issue.expected},收到 ${issue.received}`
    case 'too_small': {
      if (issue.type === 'string') return `至少 ${issue.minimum} 个字符`
      if (issue.type === 'number') return `不能小于 ${issue.minimum}`
      if (issue.type === 'array') return `至少 ${issue.minimum} 个元素`
      return issue.message
    }
    case 'too_big': {
      if (issue.type === 'string') return `最多 ${issue.maximum} 个字符`
      if (issue.type === 'number') return `不能大于 ${issue.maximum}`
      return issue.message
    }
    case 'invalid_string': {
      if (issue.validation === 'email') return '邮箱格式不正确'
      if (issue.validation === 'url') return 'URL 格式不正确'
      return issue.message
    }
    case 'invalid_enum':
      return `必须是 ${issue.options.join('、')} 之一`
    default:
      return issue.message
  }
}
 
export function formatValidationError(error: ZodError): ApiValidationError {
  const details: Record<string, string[]> = {}
  for (const issue of error.issues) {
    const path = issue.path.join('.')
    if (!details[path]) details[path] = []
    details[path].push(issueToMessage(issue))
  }
  return {
    code: 'VALIDATION_ERROR',
    message: '请求参数校验失败',
    details,
  }
}

然后在 zValidator 的 hook 中使用:

// src/middleware/validate.ts
import type { ValidationTargets } from 'hono'
import { zValidator } from '@hono/zod-validator'
import type { ZodSchema } from 'zod'
import { formatValidationError } from '../utils/validation-error'
 
export function validate<T extends keyof ValidationTargets>(
  target: T,
  schema: ZodSchema
) {
  return zValidator(target, schema, (result, c) => {
    if (!result.success) {
      return c.json(formatValidationError(result.error), 400)
    }
  })
}

每个路由只关心 schema 定义,错误格式化由中间件统一处理:

// src/routes/users.ts
app.post('/users', validate('json', createUserSchema), (c) => {
  const data = c.req.valid('json')
  return c.json({ id: 1, ...data }, 201)
})

链路是:Zod 校验失败 → validate 中间件拦截 → formatValidationError 转换 → 统一 JSON 返回。

4. 字段级错误映射

嵌套对象的错误路径处理容易被忽略。对于注册接口中嵌套的 address 对象,Zod 产生的 path 会是 ['address', 'province'] 这样的数组。formatValidationError 已经用点号拼成了 address.province,前端可以直接用来关联表单字段。

如果涉及数组索引,path 会是 ['items', 0, 'name'],join 后变成 items.0.name,和 React Hook Form 等表单库的路径格式一致,可以直接对接。

如果后端和前端字段命名不一致(如后端 user_name、前端 userName),可以在格式化函数里加一层映射:

// utils/format-validation-error.ts
const fieldAliases: Record<string, string> = {
  user_name: 'userName',
  phone_number: 'phoneNumber',
}
 
function normalizeFieldPath(path: string): string {
  return path.split('.').map((s) => fieldAliases[s] ?? s).join('.')
}

5. 自定义错误消息与全局 error map

Zod 的每个校验方法都接受 .message 参数,可以覆盖默认文本:

// schemas/user.ts
const createUserSchema = z.object({
  username: z.string().min(3, { message: '用户名至少 3 个字符' }),
  email: z.string().email({ message: '请输入有效的邮箱地址' }),
})

直接在 schema 里写中文是最快的方案,但有几个问题需要注意:消息和 schema 耦合在一起,难以支持多语言;Zod 同时支持 z.setErrorMap() 全局覆盖和 .message 局部覆盖,两者共存时容易混乱。

实用的分层策略是:全局 error map 处理通用场景,.message 处理业务特化:

// schemas/error-messages.ts
import { z, ZodErrorMap } from 'zod'
 
const chineseErrorMap: ZodErrorMap = (issue, ctx) => {
  switch (issue.code) {
    case z.ZodIssueCode.invalid_type:
      return { message: `期望 ${issue.expected},收到 ${issue.received}` }
    case z.ZodIssueCode.too_small:
      if (issue.type === 'string') return { message: `至少 ${issue.minimum} 个字符` }
      if (issue.type === 'number') return { message: `不能小于 ${issue.minimum}` }
      return { message: ctx.defaultError }
    case z.ZodIssueCode.invalid_string:
      if (issue.validation === 'email') return { message: '请输入有效的邮箱地址' }
      return { message: ctx.defaultError }
    default:
      return { message: ctx.defaultError }
  }
}
 
// 应用入口设置一次
z.setErrorMap(chineseErrorMap)

需要更具体的提示时,再用 .message 覆盖:

// schemas/user.ts
const createUserSchema = z.object({
  password: z.string().min(8, { message: '密码至少 8 位,需包含字母和数字' }),
})

全局兜底和局部特化各司其职。

6. 国际化策略

API 需要支持多语言时,有两种路径:

方案 A:后端根据请求语言返回对应消息。 后端读取 X-Lang 头,在格式化时选择对应语言的文案。优点是前端拿到 message 直接展示;缺点是后端需要维护多套文案,且 Zod 的 error map 是全局的,并发请求中不同语言可能互相覆盖。

方案 B:后端只返回错误码,前端负责翻译。 details 里不存 message 文本,存错误码和参数:

// utils/format-validation-error.ts
export function formatValidationErrorWithCodes(error: ZodError) {
  const details: Record<string, Array<{ code: string; params?: Record<string, unknown> }>> = {}
  for (const issue of error.issues) {
    const path = issue.path.join('.')
    if (!details[path]) details[path] = []
    details[path].push({
      code: mapZodCodeToMessageKey(issue),
      params: extractParams(issue),
    })
  }
  return { code: 'VALIDATION_ERROR', message: 'Validation failed', details }
}

前端拿到 validation.min.string&#123; minimum: 3 &#125;,在自己的 i18n 系统里查文案并填充参数。

选哪种取决于团队基础设施。前端已有 i18n 框架(vue-i18n、react-intl 等),方案 B 更自然;没有的话方案 A 上手更快。小项目初期直接用中文 error map 就够了,不需要过早引入 i18n。

7. 安全考量

错误消息是攻击者获取系统信息的入口之一。需要避免的情况:

  1. 暴露 schema 结构——告诉客户端「字段 internal_role 不存在」,会让攻击者知道系统有哪些字段
  2. 暴露数据库字段名——校验 path 用了数据库列名(如 usr_email_addr),内部命名不应暴露
  3. 暴露校验库实现——ZodErrorinvalid_enum_value_recieved 这类标识对攻击者有用,对正常客户端没有价值
  4. 暴露业务规则细节——「密码不能包含连续 3 个相同字符」对用户有用,对攻击者是密码策略的精确规格

实用的裁剪方式——按环境控制信息粒度:

// utils/format-validation-error.ts
export function sanitizeErrorDetails(
  details: Record<string, string[]>,
  isProduction: boolean
): Record<string, string[]> {
  if (!isProduction) return details
 
  const sanitized: Record<string, string[]> = {}
  for (const [field, messages] of Object.entries(details)) {
    // 过滤内部字段
    if (field.startsWith('_') || field.includes('internal')) continue
    // 生产环境用通用消息替换
    sanitized[field] = messages.map(() => '格式不正确')
  }
  return sanitized
}

对于敏感字段(密码、token),校验失败时只返回「该字段不合法」,不告诉攻击者具体哪条规则没通过,防止逐位试探。

8. 测试错误消息的一致性

错误消息规范化后,需要确保输出稳定。前端依赖这份格式展示错误提示,格式变了前端就会出问题。

formatValidationError 写单元测试,覆盖常见场景:

// tests/utils/validation-error.test.ts
import { z } from 'zod'
import { formatValidationError } from '../../src/utils/validation-error'
 
describe('formatValidationError', () => {
  it('should format multiple field errors', () => {
    const schema = z.object({
      name: z.string().min(1),
      email: z.string().email(),
    })
    const result = schema.safeParse({ name: '', email: 'bad' })
    expect(result.success).toBe(false)
    if (!result.success) {
      const formatted = formatValidationError(result.error)
      expect(formatted.code).toBe('VALIDATION_ERROR')
      expect(formatted.details).toHaveProperty('name')
      expect(formatted.details).toHaveProperty('email')
    }
  })
 
  it('should handle nested paths', () => {
    const schema = z.object({
      address: z.object({ street: z.string().min(1) }),
    })
    const result = schema.safeParse({ address: { street: '' } })
    expect(result.success).toBe(false)
    if (!result.success) {
      const formatted = formatValidationError(result.error)
      expect(formatted.details).toHaveProperty('address.street')
    }
  })
})

再加一个集成测试,对路由发空请求,断言错误格式符合约定:

// tests/routes/users.test.ts
it('should return standardized error format', async () => {
  const res = await app.request('/users', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({}),
  })
  expect(res.status).toBe(400)
  const body = await res.json()
  expect(body.code).toBe('VALIDATION_ERROR')
  expect(body.details).toBeDefined()
})

这些测试的目的是防止错误格式被意外打破。

延伸阅读

总结

错误消息规范化的核心目标:

  1. 格式统一——所有校验失败走同一个响应结构(code + message + details),前端不需要按接口猜测错误格式
  2. 信息安全——生产环境裁剪掉 schema 细节、内部字段名和校验库标识,只暴露客户端需要的信息
  3. 可维护——格式化逻辑集中在一个工具模块里,改消息文案或调整结构只改一处

实现链路是:Zod 校验失败 → zValidator hook 拦截 → formatValidationError 转换 → 返回统一 JSON。业务代码不需要关心错误消息的格式、语言和安全裁剪,全部由中间件和工具函数承担。

下一篇讲 DTO 与 Schema 分层——当校验 schema 和业务数据结构出现差异时,怎样用 DTO 层做转换,避免让外部输入的形状影响内部数据模型。