Chapter 06

把错误变成 UI

校验本身只是半件事——真正给用户价值的是"哪个字段错了、为什么错、怎么改"。这章讲 Zod 错误数据结构、怎么映射到表单字段、怎么给全站换成中文。

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 好读。

本章小结