起点:一个基础 User
const UserRow = z.object({ id: z.string().uuid(), email: z.string().email(), name: z.string().min(1), password: z.string().min(8), role: z.enum(["admin", "user"]), createdAt: z.date(), updatedAt: z.date(), });
.pick:只选几个字段
const LoginInput = UserRow.pick({ email: true, password: true }); // { email: string; password: string } LoginInput.parse({ email: "a@b.com", password: "12345678" });
.omit:去掉几个字段
const PublicUser = UserRow.omit({ password: true }); // 所有字段 - password const CreateUserInput = UserRow.omit({ id: true, createdAt: true, updatedAt: true, }); // 创建用户时不传这三个 —— 由 DB 生成
pick vs omit 的选择
保留的字段少(< 一半)用
保留的字段少(< 一半)用
pick,去掉的字段少用 omit。二者语义等价,只是哪种写出来更清晰。
.partial:全部变可选
const UpdateUserInput = UserRow .omit({ id: true, createdAt: true, updatedAt: true }) .partial(); // 所有字段都 optional —— PATCH 请求的典型 body UpdateUserInput.parse({ name: "New Name" }); // ✓ 只改 name UpdateUserInput.parse({}); // ✓ 啥也没改 // 只让部分字段 optional UserRow.partial({ name: true, email: true }); // name 和 email 变 optional,其他还是 required
.required:全部变必填
const Base = z.object({ id: z.string().optional(), name: z.string().optional(), }); Base.required(); // { id: string; name: string } —— 去掉所有 optional Base.required({ id: true }); // 只让 id 必填,name 还是 optional
.extend:添加字段
const UserWithProfile = UserRow.extend({ avatar: z.string().url().optional(), bio: z.string().max(500).optional(), }); // 覆盖已有字段 const AdminUser = UserRow.extend({ role: z.literal("admin"), // 从 enum 收窄成 literal });
.merge:合并两个对象 schema
const Timestamps = z.object({ createdAt: z.date(), updatedAt: z.date(), }); const SoftDelete = z.object({ deletedAt: z.date().nullable(), }); const BaseEntity = Timestamps.merge(SoftDelete); // { createdAt, updatedAt, deletedAt } const User = z .object({ id: z.string(), name: z.string() }) .merge(BaseEntity);
.merge(B) 和 .extend(B.shape) 几乎等价——后加入的字段会覆盖同名字段。合并两个完整对象用 merge,加几个字段用 extend。
.deepPartial:递归 optional
const Post = z.object({ title: z.string(), author: z.object({ name: z.string(), age: z.number(), }), }); Post.partial(); // { title?: string; author?: { name: string; age: number } } // ← 只有顶层变 optional Post.deepPartial(); // { title?: string; author?: { name?: string; age?: number } } // ← 嵌套对象里的字段也都 optional
v4 的 deepPartial 状态
Zod v4 对
Zod v4 对
deepPartial 的支持有一些变化——嵌套 array/union/intersection 里的 optional 处理不如 v3 直观。复杂场景先写 unit test 验证一下,或者手写对应字段的 partial。
组合链式示例
const UserRow = z.object({ id: z.string().uuid(), email: z.string().email(), name: z.string().min(1), password: z.string().min(8), role: z.enum(["admin", "user"]), createdAt: z.date(), updatedAt: z.date(), }); // 创建:不要 id、时间戳,password 必填 const CreateUser = UserRow.omit({ id: true, createdAt: true, updatedAt: true, }); // 更新:全字段 optional,但不允许改 id 和时间戳 const UpdateUser = CreateUser.partial(); // 公开:不要 password const PublicUser = UserRow.omit({ password: true }); // 登录:只要 email + password const LoginInput = UserRow.pick({ email: true, password: true }); // 管理员列表:公开版 + 只看 role=admin const AdminUser = PublicUser.extend({ role: z.literal("admin"), });
一份真源 + 五六行组合 = 完整 CRUD 的 schema 族。改 UserRow 的字段 → 全家族自动跟着改。
TS 类型派生
type UserRow = z.infer<typeof UserRow>; type CreateUser = z.infer<typeof CreateUser>; type UpdateUser = z.infer<typeof UpdateUser>; type PublicUser = z.infer<typeof PublicUser>; type LoginInput = z.infer<typeof LoginInput>; // 每一个都是独立的 TS 类型,可以单独 import 用
对比:TypeScript 原生 vs Zod 组合
| 操作 | TypeScript | Zod |
|---|---|---|
| 挑字段 | Pick<User, "id" | "name"> | User.pick({ id: true, name: true }) |
| 去字段 | Omit<User, "password"> | User.omit({ password: true }) |
| 全 optional | Partial<User> | User.partial() |
| 全 required | Required<User> | User.required() |
| 合并 | User & Profile | User.merge(Profile) |
| 加字段 | User & { x: string } | User.extend({ x: z.string() }) |
Zod 的组合操作和 TS 工具类型一一对应——学 Zod 约等于学了一遍 TS 工具类型。
shape 访问 + 手动引用
// 从 schema 里拿子 schema const EmailSchema = UserRow.shape.email; // z.string().email() // 在其它 schema 里复用 const ContactForm = z.object({ email: UserRow.shape.email, message: z.string().min(10), });
.shape 返回一个 { fieldName: subSchema }——像访问普通对象那样拿子 schema。
版本演化:加字段
// v1 const UserV1 = z.object({ id: z.string(), name: z.string(), }); // v2:新增 avatar,旧数据兼容用 optional / default const UserV2 = UserV1.extend({ avatar: z.string().url().default("/default.png"), }); // v3:去掉 name,改成 firstName + lastName const UserV3 = UserV2.omit({ name: true }).extend({ firstName: z.string(), lastName: z.string(), });
读取旧版本存储时先用 UserV1.parse,再做 migration 转成 V3。每次 schema 升级保留旧 schema 可以做版本兼容。
DB 行 → API 响应
// 从 Drizzle 生成 const UserRowSchema = createSelectSchema(users); // { id, email, password_hash, role, created_at, updated_at } // 对外暴露的 public 版 const PublicUser = UserRowSchema .omit({ password_hash: true }) .transform((u) => ({ id: u.id, email: u.email, role: u.role, createdAt: u.created_at, // snake_case → camelCase updatedAt: u.updated_at, })); // API 一层:PublicUser.parse(dbRow) // → 自动剥密码 + 字段名转换
多 schema 文件组织
// schemas/user.ts export const UserRow = z.object({...}); export const CreateUser = UserRow.omit({...}); export const UpdateUser = CreateUser.partial(); export const PublicUser = UserRow.omit({ password: true }); export type User = z.infer<typeof UserRow>; export type CreateUserInput = z.infer<typeof CreateUser>; // ...
推荐一份资源一个文件,导出 schema + 类型。跨资源的组合放在单独的 schemas/index.ts。
注意事项
组合操作只在 ZodObject 上可用
.pick/.omit/.partial/.extend/.merge 都是 z.object(...) 上的方法——如果 schema 被 .refine() 或 .transform() 包过,这些方法就不能直接用。需要先在原始 object schema 上组合,最后再加 refine/transform。
// ❌ 错误顺序 const User = z.object({...}).refine(...); User.pick({...}); // TypeError: pick is not a function // ✓ 正确顺序 const UserBase = z.object({...}); const User = UserBase.refine(...); const PickedUser = UserBase.pick({...}).refine(...);
本章小结
.pick / .omit选字段,对应 TS 的Pick / Omit.partial / .required改 optional 状态,对应Partial / Required.extend / .merge加字段,对应&交叉类型- 组合操作只在 ZodObject 上,refine/transform 要最后加
- 一份基础 schema 派生 CRUD 全家族——改源头,全家族跟着改