ZodError 结构
const schema = z.object({ name: z.string().min(3), age: z.number().int().positive(), }); const res = schema.safeParse({ name: "ab", age: -1 }); if (!res.success) { res.error.issues; // [ // { code: "too_small", minimum: 3, path: ["name"], message: "..." }, // { code: "too_small", minimum: 0, path: ["age"], message: "..." }, // ] }
每条 issue 包含:
code
错误类型(invalid_type / too_small / too_big / invalid_string / custom 等)。根据 code 可以程序化处理。
path
字段路径数组(
["user", "address", "city"])——映射表单字段时用得上。message
人类可读的错误信息(可被 errorMap 覆盖)。
其它字段
视 code 而定:too_small 有 minimum、invalid_type 有 expected/received、custom 有 params 等。
flatten:扁平化成 { field: [errors] }
const res = schema.safeParse({ name: "ab", age: -1 }); if (!res.success) { const flat = res.error.flatten(); // { // formErrors: [], ← 顶层错误(跨字段 refine) // fieldErrors: { // name: ["String must contain at least 3 character(s)"], // age: ["Number must be greater than 0"], // }, // } }
最常用的形式——直接给 React Hook Form / 表单库当 errors 对象。
format:保留嵌套结构
const schema = z.object({ user: z.object({ name: z.string().min(1) }), }); const res = schema.safeParse({ user: { name: "" } }); if (!res.success) { res.error.format(); // { // _errors: [], // user: { // _errors: [], // name: { _errors: ["String must contain at least 1 character(s)"] }, // }, // } }
嵌套表单(比如 address.city)常用 format——每层都有 _errors 可直接用。
自定义 errorMap(全局中文化)
// lib/zod-zh.ts import { z } from "zod"; const customErrorMap: z.ZodErrorMap = (issue, ctx) => { switch (issue.code) { case z.ZodIssueCode.invalid_type: if (issue.received === "undefined") return { message: "必填" }; 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}` }; if (issue.type === "array") return { message: `至少 ${issue.minimum} 项` }; break; case z.ZodIssueCode.too_big: if (issue.type === "string") return { message: `最多 ${issue.maximum} 个字符` }; break; case z.ZodIssueCode.invalid_string: if (issue.validation === "email") return { message: "邮箱格式不正确" }; if (issue.validation === "url") return { message: "URL 格式不正确" }; if (issue.validation === "uuid") return { message: "UUID 格式不正确" }; break; case z.ZodIssueCode.invalid_enum_value: return { message: `必须是 ${issue.options.join(" / ")} 之一` }; } return { message: ctx.defaultError }; // 兜底用默认 }; z.setErrorMap(customErrorMap); // 全局生效
一次注册,所有 schema 都用中文报错。Zod v4 也提供了官方 zod-i18n-map 包,开箱即用。
用官方 i18n 包
pnpm add zod-i18n-map i18next
import i18next from "i18next"; import { zodI18nMap } from "zod-i18n-map"; import zhCN from "zod-i18n-map/locales/zh-CN/zod.json"; i18next.init({ lng: "zh-CN", resources: { "zh-CN": { zod: zhCN } }, }); z.setErrorMap(zodI18nMap(i18next));
单 schema 覆盖
const schema = z.string({ required_error: "用户名必填", invalid_type_error: "用户名必须是字符串", }); // 单个方法也能覆盖 z.string().min(3, { message: "名字太短了,至少 3 个字" }); // safeParse 时也能临时传 schema.safeParse(data, { errorMap: customMap, });
优先级:调用时 > schema 内置 > 全局 errorMap > 默认英文。
表单集成示例(React)
const SignUp = z.object({ email: z.string().email(), password: z.string().min(8), }); function Form() { const [errors, setErrors] = useState<Record<string, string[]>>({}); const onSubmit = (e: FormEvent) => { e.preventDefault(); const data = Object.fromEntries(new FormData(e.currentTarget)); const res = SignUp.safeParse(data); if (!res.success) { setErrors(res.error.flatten().fieldErrors); return; } submit(res.data); }; return ( <form onSubmit={onSubmit}> <input name="email" /> {errors.email?.map((e) => <p className="err">{e}</p>)} <input name="password" type="password" /> {errors.password?.map((e) => <p className="err">{e}</p>)} <button>注册</button> </form> ); }
更推荐用 React Hook Form(下一章),但"纯手写"也不过如此——safeParse + flatten + 渲染。
嵌套 path 的处理
const schema = z.object({ user: z.object({ address: z.object({ city: z.string().min(1), }), }), }); const res = schema.safeParse({ user: { address: { city: "" } } }); if (!res.success) { res.error.issues[0].path; // ["user", "address", "city"] // 拼成 lodash 的 "user.address.city" const fieldPath = res.error.issues[0].path.join("."); // → "user.address.city" }
数组 path
const schema = z.object({ tags: z.array(z.string().min(2)), }); schema.safeParse({ tags: ["ok", "a"] }); // issues[0].path === ["tags", 1] ← 第 2 个元素错了 // 前端可以高亮这条
API 边界:统一错误响应
// Express/Fastify 中间件 app.use((err, req, res, next) => { if (err instanceof z.ZodError) { return res.status(400).json({ error: "ValidationError", issues: err.flatten().fieldErrors, }); } next(err); }); // 客户端 const res = await fetch("/api/signup", { method: "POST", body }); if (res.status === 400) { const { issues } = await res.json(); // 渲染 issues 到表单 }
tryCatch vs safeParse
// ❌ 用 try/catch 当控制流 try { const data = schema.parse(input); submit(data); } catch (e) { if (e instanceof z.ZodError) { ... } } // ✓ 用 safeParse const res = schema.safeParse(input); if (res.success) { submit(res.data); } else { // res.error }
原则
"预期会失败"用
"预期会失败"用
safeParse(表单、外部输入)。"失败等于 bug"用 parse(启动 env 校验、内部契约)。不要混用,更不要 try/catch(parse) 模拟 safeParse——性能差、语义乱。
自定义 issue code
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "密码已使用过", params: { reason: "PASSWORD_HISTORY" }, // ← 前端可以根据 reason 处理 }); // 前端 const pwdErr = res.error.issues.find( (i) => i.code === "custom" && (i as any).params?.reason === "PASSWORD_HISTORY" ); if (pwdErr) showHistoryDialog();
日志化
if (!res.success) { logger.error({ msg: "validation_failed", path: req.path, issues: res.error.issues.map((i) => ({ path: i.path.join("."), code: i.code, message: i.message, })), }); }
不要直接日志 ZodError 对象——.issues 才是结构化的;.message 是一大坨多行字符串,不适合搜索。
prettifyError(v4 新)
if (!res.success) { console.log(z.prettifyError(res.error)); // ✖ Invalid email // → at email // ✖ Number must be greater than 0 // → at age }
CLI 场景或者开发调试时直接打印美化后的错误信息——比 JSON 好读。
本章小结
- ZodError 由
issues[]组成,每条有code / path / message .flatten()拿扁平 fieldErrors,.format()拿嵌套结构- 全局中文化用
z.setErrorMap+ 自定义 map,或装zod-i18n-map - 单字段用
{ message: "..." }覆盖 - 外部输入用 safeParse,内部契约用 parse——别用 try/catch 模拟