Chapter 05

一份 schema 派生十份

CRUD 一个 User 资源就至少要 5 份 schema:UserRow(数据库)、CreateUserInput(没 id)、UpdateUserInput(全字段 optional)、PublicUser(不含 password)、LoginPayload(只挑几个字段)。手写五遍痛苦,用组合操作三行搞定。

起点:一个基础 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 对 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 组合

操作TypeScriptZod
挑字段Pick<User, "id" | "name">User.pick({ id: true, name: true })
去字段Omit<User, "password">User.omit({ password: true })
全 optionalPartial<User>User.partial()
全 requiredRequired<User>User.required()
合并User & ProfileUser.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(...);

本章小结