守门员模式:在系统边界校验
Zod 最重要的使用原则:在数据进入系统的地方校验,不在内部代码到处加。
// 系统边界(要加) // ✓ 入口:HTTP / GraphQL / CLI args / env / 表单 / 文件上传 // ✓ 出口:发给外部 API 的 payload / 写入消息队列 // ✓ 持久层:localStorage / cookie / DB 输出反序列化 // 内部代码(不要加) // ✗ function 参数(TS 已经保护) // ✗ 内部对象传递(已经是 User 类型了) // ✗ 每层都 parse 一遍
防御式校验在边界做一次就够了——TS 保证内部代码拿到的就是校验过的类型。
环境变量:启动即校验
// src/env.ts import { z } from "zod"; const envSchema = z.object({ NODE_ENV: z.enum(["development", "production", "test"]), DATABASE_URL: z.string().url(), REDIS_URL: z.string().url(), PORT: z.coerce.number().int().min(1).max(65535).default(3000), OPENAI_API_KEY: z.string().startsWith("sk-"), STRIPE_SECRET: z.string().startsWith("sk_"), SENTRY_DSN: z.string().url().optional(), }); const parsed = envSchema.safeParse(process.env); if (!parsed.success) { console.error("❌ 环境变量错误:"); console.error(parsed.error.flatten().fieldErrors); process.exit(1); } export const env = parsed.data;
在 main.ts 最开头引入 ./env。启动时发现 env 错直接崩,比运行半小时后报 500 友好一万倍。
// 也可以用 @t3-oss/env-core 封装得更优雅 import { createEnv } from "@t3-oss/env-core"; export const env = createEnv({ server: { DATABASE_URL: z.string().url() }, client: { NEXT_PUBLIC_APP_URL: z.string().url() }, runtimeEnv: process.env, });
LLM 输出:结构化解析
const Classification = z.object({ category: z.enum(["bug", "feature", "question", "other"]), priority: z.enum(["low", "medium", "high"]), confidence: z.number().min(0).max(1), reasoning: z.string(), }); const { object } = await generateObject({ model: openai("gpt-4o"), schema: Classification, prompt: `分类这条 issue: ${issue.body}`, }); // object 完全符合 schema —— OpenAI 会反复重试直到合法
LLM 时代 Zod 有了新作用:给模型约束输出形状。SDK 把 schema 转成 JSON Schema 喂给模型,模型保证输出符合。
缓存反序列化
const CachedUser = z.object({ id: z.string(), name: z.string(), updatedAt: z.coerce.date(), // Redis 里 Date 变字符串,解析回 Date }); async function getUser(id: string) { const cached = await redis.get(`user:${id}`); if (cached) { const res = CachedUser.safeParse(JSON.parse(cached)); if (res.success) return res.data; // 缓存结构变了(schema 升级) → 忽略缓存,重新查 await redis.del(`user:${id}`); } const user = await db.user.findUnique({ where: { id } }); await redis.set(`user:${id}`, JSON.stringify(user)); return user; }
用 safeParse 而不是 parse——缓存污染不该让主流程崩,降级到 DB 查即可。
localStorage 迁移
const SettingsV1 = z.object({ theme: z.enum(["light", "dark"]), }); const SettingsV2 = z.object({ theme: z.enum(["light", "dark", "system"]), // 新增 system fontSize: z.number().default(14), // 新增字段 }); function loadSettings() { const raw = localStorage.getItem("settings"); if (!raw) return SettingsV2.parse({}); // 默认值 const parsed = JSON.parse(raw); // 先试 v2 const v2 = SettingsV2.safeParse(parsed); if (v2.success) return v2.data; // 再试 v1,迁移 const v1 = SettingsV1.safeParse(parsed); if (v1.success) { return SettingsV2.parse({ ...v1.data, fontSize: 14 }); } return SettingsV2.parse({}); }
schema 目录组织
src/ ├─ schemas/ │ ├─ user.ts # UserRow, CreateUser, UpdateUser, PublicUser │ ├─ post.ts │ ├─ comment.ts │ ├─ api/ # HTTP 边界的 schema │ │ ├─ sign-up.ts │ │ └─ sign-in.ts │ ├─ llm/ # LLM 输出 schema │ │ └─ classification.ts │ ├─ env.ts │ └─ index.ts # 统一导出 ├─ server/ └─ client/
原则:
- 一个资源一个文件(user.ts 包含 User 相关全家族)
- API 层 / LLM 层 / 持久层 分开目录
- 类型也从 schema 派生导出(
export type User = z.infer<typeof UserRow>) - schema 之间引用用 extend/pick/omit,不重复定义
陷阱 1:用 parse 当控制流
// ❌ 错 try { const data = schema.parse(input); } catch (e) { // 用异常控制流 } // ✓ 对 const res = schema.safeParse(input); if (!res.success) { ... }
parse 抛异常的性能开销比 safeParse 高不少,而且 catch 会遮住意外的非 Zod 异常。
陷阱 2:z.coerce.boolean 的陷阱
// ❌ z.coerce.boolean().parse("false"); // → true (!) 因为 Boolean("false") === true // ✓ z.enum(["true", "false"]).transform((s) => s === "true").parse("false"); // → false
陷阱 3:z.date 不吃日期字符串
z.date().parse("2026-05-06"); // ❌ ZodError: 期望 Date 实例 z.coerce.date().parse("2026-05-06"); // ✓ Date z.iso.date().parse("2026-05-06"); // ✓ 但返回的是字符串(不是 Date)
JSON 传输场景用 z.iso.date(stays string);要真 Date 实例用 z.coerce.date。
陷阱 4:strict 模式的连锁反应
const UserV1 = z.object({ id: z.string() }).strict(); UserV1.parse({ id: "1", newField: "x" }); // ZodError: Unrecognized key "newField" // 后端加了字段 → 老前端全挂
API 响应 schema 一般不要用 strict,用默认的 strip(丢掉未知字段)。只有入参严控才用 strict。
陷阱 5:schema 链式调用顺序
// ❌ min 在 transform 之后 z.string().transform((s) => s.trim()).min(1); // min 检查的是 transform 之前的长度 —— 空格不会被 trim 掉再算 // ✓ 用 pipe z.string().transform((s) => s.trim()).pipe(z.string().min(1)); // ✓ 或先 trim z.string().trim().min(1);
陷阱 6:async schema 用同步 parse
const schema = z.string().refine(async ...); schema.parse(x); // ❌ 抛错:Async schema needs parseAsync await schema.parseAsync(x); // ✓
陷阱 7:大对象 parse 成本
// 100k 字段的对象每次 parse 慢 // ——只在入口校验一次,不要每个 API 层都 parse 一遍 // 大数组可以抽样校验 const sample = items.slice(0, 10); z.array(Item).parse(sample); // 只校验前 10 个 // 剩下的做 best-effort 处理
陷阱 8:z.any / z.unknown 的使用
z.any(); // 完全放弃校验,慎用 z.unknown(); // 类型是 unknown,至少后续要用前强制 narrow // 更好:具体列出接受的形状 z.union([z.string(), z.number(), z.object({...})]);
陷阱 9:optional vs undefined vs nullable
// 常见混淆 z.string().optional() // string | undefined —— "字段可不传" z.string().nullable() // string | null —— "字段必传但可为 null" z.string().nullish() // 两者都接受 // API 设计建议: // - 表单字段用 optional(未填就不传) // - DB nullable 列用 nullable(明确存 null) // - 不要滥用 nullish —— "两者都行"通常意味着 API 设计不清晰
性能优化
- schema 定义放顶层——不要每次请求都重新创建
z.object({...}) - 优先 discriminatedUnion——O(1) 跳转比 union 的 O(n) 试错快
- 避免深 refine——每层都有回调开销
- 热点路径可以用 @zod/mini——函数式版本 bundle 小、跑得快
- 大 schema 的 TS 编译慢——拆分模块或用
interface手写类型,schema 只用作运行时
测试
import { UserRow } from "./schemas/user"; describe("UserRow schema", () => { test("accepts valid user", () => { expect(() => UserRow.parse({ id: crypto.randomUUID(), email: "a@b.com", name: "A", })).not.toThrow(); }); test("rejects short name", () => { const res = UserRow.safeParse({ ..., name: "" }); expect(res.success).toBe(false); expect(res.error.issues[0].path).toEqual(["name"]); }); });
schema 是一类"值",可以被单元测试——别让"业务规则"只存在脑子里。关键校验(密码规则、跨字段)一定要有测试。
把 schema 变文档
const User = z.object({ id: z.string().uuid().describe("用户唯一 ID"), email: z.string().email().describe("登录邮箱,接收通知"), name: z.string().describe("显示名"), }).describe("注册用户");
describe 的内容被 zod-to-openapi 转成 Swagger description,前端文档、后端代码、校验逻辑一份源。
生产检查清单
- ✅ env 启动即校验,失败进程退出
- ✅ 所有 HTTP 入口都有 zValidator / input schema
- ✅ 响应用 PublicUser(去敏感字段)而不是直接吐 DB 行
- ✅ safeParse 处理外部数据,parse 处理内部契约
- ✅ 全局 errorMap 中文化或 i18n
- ✅ schema 和 type 从一份源派生(z.infer)
- ✅ schema 单元测试覆盖关键规则
- ✅ OpenAPI 文档从 schema 自动生成
- ✅ 缓存层用 safeParse,脏数据降级重查
- ✅ schema 按资源 / 层级组织,避免重复
终章回顾
从第 1 章"TS 到不了的地方",到第 10 章生产陷阱——Zod 的核心哲学就是一句话:
一份 schema 同时是值、类型、文档、契约
在边界校验,在内部相信 TS;在失败时给结构化错误,在成功时给类型安全的数据。
这就是现代 TypeScript 应用 防御式编程 + 类型驱动开发 的最佳实践。
在边界校验,在内部相信 TS;在失败时给结构化错误,在成功时给类型安全的数据。
这就是现代 TypeScript 应用 防御式编程 + 类型驱动开发 的最佳实践。
写 schema 是早期投入,但每一行都在未来某天救你一次命。
推荐资源
- zod.dev —— 官方文档
- colinhacks/zod —— GitHub 主仓
- standardschema.dev —— 共通规范
@t3-oss/env-core—— env 校验封装drizzle-zod/prisma-zod-generator—— ORM 联动@asteasolutions/zod-to-openapi/@hono/zod-openapi—— 文档生成zod-i18n-map—— 错误信息国际化