错误消息规范化
要点
- Zod 默认的错误消息是英文的,格式也不统一——同一类校验失败可能有多种 message 写法
- 面向前端的 API 需要一份稳定的错误响应结构,让前端不用猜「这个接口返回的字段叫什么」
- 错误码(
VALIDATION_ERROR、FORMAT_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']
}
]
}这份数据对机器来说信息完整,但对前端和终端用户有三个问题:
- message 是英文——产品面向中文用户时不能直接展示
- issues 是扁平数组——嵌套对象(
address.street)的路径信息混在一起,前端需要自己按path分组 - code 和 validation 是 Zod 内部概念——
too_small、invalid_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 面向开发者,前端根据 code 和 details 组织用户展示;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 constVALIDATION_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 和 { minimum: 3 },在自己的 i18n 系统里查文案并填充参数。
选哪种取决于团队基础设施。前端已有 i18n 框架(vue-i18n、react-intl 等),方案 B 更自然;没有的话方案 A 上手更快。小项目初期直接用中文 error map 就够了,不需要过早引入 i18n。
7. 安全考量
错误消息是攻击者获取系统信息的入口之一。需要避免的情况:
- 暴露 schema 结构——告诉客户端「字段
internal_role不存在」,会让攻击者知道系统有哪些字段 - 暴露数据库字段名——校验 path 用了数据库列名(如
usr_email_addr),内部命名不应暴露 - 暴露校验库实现——
ZodError、invalid_enum_value_recieved这类标识对攻击者有用,对正常客户端没有价值 - 暴露业务规则细节——「密码不能包含连续 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()
})这些测试的目的是防止错误格式被意外打破。
延伸阅读
- 01-为什么需要请求验证 — zValidator 基础用法和自定义错误响应的入口
- 06-Zod高级校验模式 — refine、superRefine 等高级校验会产生自定义 issue,格式化时需要处理
- 07-Valibot集成实践 — 如果项目用 Valibot 替代 Zod,错误结构不同,格式化函数需要适配
- Zod 错误处理文档 — ZodError 和 error map 的官方说明
- Zod 自定义错误 — 自定义错误消息的详细用法
总结
错误消息规范化的核心目标:
- 格式统一——所有校验失败走同一个响应结构(
code+message+details),前端不需要按接口猜测错误格式 - 信息安全——生产环境裁剪掉 schema 细节、内部字段名和校验库标识,只暴露客户端需要的信息
- 可维护——格式化逻辑集中在一个工具模块里,改消息文案或调整结构只改一处
实现链路是:Zod 校验失败 → zValidator hook 拦截 → formatValidationError 转换 → 返回统一 JSON。业务代码不需要关心错误消息的格式、语言和安全裁剪,全部由中间件和工具函数承担。
下一篇讲 DTO 与 Schema 分层——当校验 schema 和业务数据结构出现差异时,怎样用 DTO 层做转换,避免让外部输入的形状影响内部数据模型。