Chapter 04

不止校验,还能变换

内置的 .min/.max/.email 应付八成场景,但业务总有奇葩规则("密码必须含大小写且和确认密码一致")。refine 补内置的空缺,transform 把数据从"输入形态"变成"内部形态"——这一章就是把 schema 从"只会判断对错"升级成"能改造数据"。

内置约束速查

// string
z.string().min(3).max(20);
z.string().email();
z.string().url();
z.string().uuid();
z.string().regex(/^\d+$/);
z.string().datetime();        // ISO 8601
z.string().ip();              // v4 或 v6
z.string().emoji();
z.string().cuid();
z.string().cuid2();
z.string().ulid();
z.string().nanoid();
z.string().base64();
z.string().jwt();

// v4 新增的 z.iso 命名空间
z.iso.date();             // "2026-05-06"
z.iso.datetime();         // "2026-05-06T10:00:00Z"
z.iso.time();             // "10:00:00"
z.iso.duration();         // ISO 8601 duration

自定义错误信息

z.string({
  required_error: "用户名必填",
  invalid_type_error: "用户名必须是字符串",
}).min(3, { message: "至少 3 个字符" })
  .max(20, { message: "最多 20 个字符" })
  .regex(/^[a-z0-9_]+$/, { message: "只能用小写字母、数字、下划线" });

每个约束都支持第二个参数覆盖错误信息。Zod v4 统一成 { message } 形式,以前的 { params } 还在但不推荐。

refine:单字段自定义校验

const schema = z.string().refine(
  (val) => val.startsWith("https://"),
  { message: "必须以 https:// 开头" }
);

// 带 path(放到嵌套 schema 里时用)
z.string().refine(
  (v) => v.length % 2 === 0,
  { message: "长度必须是偶数", path: ["username"] }
);

refine 返回 true 通过,false 报错。它只能读当前字段,不能改;只能一条错误。

superRefine:多条错误 / 嵌套逻辑

const Password = z.string().superRefine((val, ctx) => {
  if (val.length < 8) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: "至少 8 位",
    });
  }
  if (!/[A-Z]/.test(val)) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: "至少一个大写字母",
    });
  }
  if (!/[0-9]/.test(val)) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: "至少一个数字",
    });
  }
});

Password.safeParse("abc");
// issues 里会有 3 条错误

superRefine 用 ctx.addIssue 可以添加多条错误——内置约束每失败一条就加一次,用户一次看见所有问题(比一次只提示一条友好)。

refine vs superRefine
单条规则用 refine;多条规则或需要嵌套 path 用 superRefine。后者还能根据数据动态决定加哪几条 issue。

跨字段校验

const SignUp = z
  .object({
    password: z.string().min(8),
    confirm: z.string(),
  })
  .refine((data) => data.password === data.confirm, {
    message: "两次密码不一致",
    path: ["confirm"],     // ← 错误归到 confirm 字段
  });

SignUp.safeParse({ password: "abc12345", confirm: "xxx" });
// issues[0].path === ["confirm"]
// 前端就能把错误展示到 confirm 输入框下

这是 refine 最典型的用法——"A 字段等于 B 字段" / "结束日期晚于开始日期" / "折扣价低于原价"——都是整个对象级别的校验。

transform:数据变形

const schema = z.string().transform((val) => val.trim().toLowerCase());

schema.parse("  HELLO  ");
// → "hello"

// 变类型
const DateFromString = z
  .string()
  .transform((s) => new Date(s))
  .refine((d) => !isNaN(d.getTime()), "不是合法日期");

DateFromString.parse("2026-05-01");
// → Date 实例

type T = z.infer<typeof DateFromString>;   // Date

transform 把输入类型改成输出类型——输入是 string,输出是 Date。z.infer 自动感知变化,一套 schema 既完成校验又完成解析。

input vs output 类型

const AgeFromString = z.string().transform(Number);

type In = z.input<typeof AgeFromString>;    // string
type Out = z.output<typeof AgeFromString>;   // number
type T = z.infer<typeof AgeFromString>;     // number(等于 output)

有 transform / default 时,input 类型 ≠ output 类型。z.infer 默认取 output(解析后);z.input 拿解析前。

transform 里加 issue

const NumFromString = z.string().transform((val, ctx) => {
  const n = parseFloat(val);
  if (isNaN(n)) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: "不是合法数字",
    });
    return z.NEVER;   // 告诉 TS 走不下去了
  }
  return n;
});

NumFromString.parse("3.14");   // → 3.14
NumFromString.parse("abc");    // ZodError

解析 + 校验合体的写法——不合法就 addIssue + 返回 z.NEVER

pipe:校验 + 变换 + 再校验

const PortFromString = z
  .string()
  .regex(/^\d+$/, "必须是数字")
  .transform(Number)
  .pipe(z.number().int().min(1).max(65535));

PortFromString.parse("8080");      // → 8080
PortFromString.parse("99999");     // ZodError: 超出范围
PortFromString.parse("abc");       // ZodError: 必须是数字

.pipe(下一个 schema)——先用前面的校验,transform 产出中间结果,再喂给后面的 schema 继续校验。管道式数据流。

preprocess:预处理

const DateOrString = z.preprocess(
  (arg) => {
    if (typeof arg === "string" || arg instanceof Date) {
      return new Date(arg);
    }
    return arg;
  },
  z.date()
);

DateOrString.parse("2026-05-01");   // Date
DateOrString.parse(new Date());        // Date

preprocess 和 transform 的区别:preprocess 在校验前跑,transform 在校验后跑。pipe 可以当"pre+post 都能做"的更通用版本。

三选一怎么选
· preprocess:我要把输入洗一下再交给后面校验(strip/parse JSON/转类型)
· transform:校验通过后再改形状(trim、lowercase、parseInt)
· pipe:需要"A 校验 → B 转换 → C 再校验"链式

业务案例:手机号归一化

const Phone = z
  .string()
  .transform((s) => s.replace(/[\s\-()]/g, ""))   // 去空格/-/括号
  .pipe(z.string().regex(/^(\+86)?1[3-9]\d{9}$/, "手机号格式不正确"))
  .transform((s) => s.startsWith("+86") ? s : "+86" + s);

Phone.parse("138 0013 8000");
// → "+8613800138000"

Phone.parse("+86-138-0013-8000");
// → "+8613800138000"

用户输入千姿百态,存库前用一个 schema 就完成了"清洗 + 校验 + 规范化"三件事。

业务案例:CSV 行解析

// CSV 里所有字段都是字符串,但我们想要 number / boolean / Date
const CsvRow = z.object({
  id: z.string().uuid(),
  name: z.string().trim(),
  age: z.coerce.number().int().min(0),
  isVip: z
    .string()
    .transform((s) => s === "1" || s.toLowerCase() === "true"),
  joinedAt: z.coerce.date(),
});

CsvRow.parse({
  id: "...",
  name: "  Alice  ",
  age: "30",
  isVip: "1",
  joinedAt: "2024-01-15",
});
// → { id, name: "Alice", age: 30, isVip: true, joinedAt: Date }

业务案例:密码强度综合校验

const StrongPassword = z
  .string()
  .min(8, "至少 8 位")
  .max(128, "不能超过 128 位")
  .superRefine((val, ctx) => {
    const checks = [
      { test: /[a-z]/.test(val), msg: "需要一个小写字母" },
      { test: /[A-Z]/.test(val), msg: "需要一个大写字母" },
      { test: /[0-9]/.test(val), msg: "需要一个数字" },
      { test: /[^a-zA-Z0-9]/.test(val), msg: "需要一个特殊字符" },
    ];

    for (const c of checks) {
      if (!c.test) {
        ctx.addIssue({
          code: z.ZodIssueCode.custom,
          message: c.msg,
        });
      }
    }

    // 常见弱密码黑名单
    const weak = ["12345678", "password", "qwerty123"];
    if (weak.includes(val.toLowerCase())) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: "密码太常见,请换一个",
      });
    }
  });

异步 refine

const UniqueEmail = z.string().email().refine(
  async (email) => {
    const exists = await db.user.findByEmail(email);
    return !exists;
  },
  { message: "邮箱已被注册" }
);

// 必须用 parseAsync / safeParseAsync
const r = await UniqueEmail.safeParseAsync("a@b.com");
异步 refine 的坑
含异步 refine 的 schema 不能用同步 parse——必须用 parseAsync,否则抛错。养成习惯在服务端一律 await schema.parseAsync

when 条件(不是 API,是模式)

Zod 没有 Yup 那样的 when,但能用 superRefine 实现条件校验:

const Order = z
  .object({
    type: z.enum(["digital", "physical"]),
    address: z.string().optional(),
    downloadUrl: z.string().url().optional(),
  })
  .superRefine((val, ctx) => {
    if (val.type === "physical" && !val.address) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: "实物订单必须填地址",
        path: ["address"],
      });
    }
    if (val.type === "digital" && !val.downloadUrl) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: "数字订单必须有下载链接",
        path: ["downloadUrl"],
      });
    }
  });

更规整的做法还是 discriminatedUnion(第 3 章),但字段太少、变化小的场景 superRefine 足够。

性能注意

本章小结