内置约束速查
// 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;多条规则或需要嵌套 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 的 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 足够。
性能注意
refine和superRefine每个字段都会调用——不要在里面做 heavy computation- 异步 refine 默认并行,但数据库查询太多会爆——在 API 层做 rate limit
- 深嵌套 schema 的 transform 会复制对象——数据大的话考虑只 transform 需要改的字段
本章小结
- 内置约束覆盖八成场景,自定义错误信息用
{ message } refine单条校验,superRefine多条 +ctx.addIssue- 跨字段校验在对象 schema 上
.refine,用path定位错误字段 transform改形状,preprocess先洗再校,pipe链式组合- 含异步 refine 必须用
parseAsync,同步 parse 会抛错