Chapter 03

组合构建复杂 schema

原始类型堆起来能搭小对象,但真实业务里有"多种 shape 之一"、"两个对象合并"、"固定长度元组"、"字段可递归"。Zod 提供一整套组合器——掌握这章就能搞定绝大多数 API schema。

z.union:多选一

const StringOrNumber = z.union([z.string(), z.number()]);
// string | number

const Pet = z.union([
  z.object({ kind: z.literal("dog"), breed: z.string() }),
  z.object({ kind: z.literal("cat"), indoor: z.boolean() }),
]);

// 简写:.or()
z.string().or(z.number());

union 会依次尝试每个成员——都失败才抛错。性能开销随成员数线性增。

z.discriminatedUnion(强烈推荐)

const Pet = z.discriminatedUnion("kind", [
  z.object({ kind: z.literal("dog"), breed: z.string() }),
  z.object({ kind: z.literal("cat"), indoor: z.boolean() }),
  z.object({ kind: z.literal("fish"), waterType: z.enum(["salt", "fresh"]) }),
]);

和 union 的区别:先看 kind 字段决定走哪个分支,一次命中——性能好,错误信息好,推断出的 TS 类型是真正的 discriminated union。

uniondiscriminatedUnion
性能O(n) 试错O(1) 跳转
错误信息列出所有失败只说正确分支的错
TS 推断union真 discriminated
要求任意每支都有同名字面量字段
业务 schema 首选 discriminatedUnion
API 响应通常都有 type / kind / status 区分字段——只要有这样的 literal 字段,就一律用 discriminatedUnion

z.intersection:合并

const Timestamps = z.object({
  createdAt: z.date(),
  updatedAt: z.date(),
});

const User = z.object({ id: z.string(), name: z.string() });

const UserWithTime = z.intersection(User, Timestamps);
// { id, name, createdAt, updatedAt }

// 等价 .and()
User.and(Timestamps);

对象合并更建议用 .merge()(下一章),因为它能做字段覆盖;intersection 更通用(可以 intersect 非对象 schema)。

z.tuple:定长元组

const Point = z.tuple([z.number(), z.number()]);
// [number, number]

const RGBA = z.tuple([
  z.number().int().min(0).max(255),
  z.number().int().min(0).max(255),
  z.number().int().min(0).max(255),
  z.number().min(0).max(1),
]);

// 变长尾部
const LogEntry = z.tuple([z.date(), z.string()]).rest(z.any());
// [Date, string, ...any[]]

z.record:动态键对象

// v4 要求同时给 key schema 和 value schema
const Scores = z.record(z.string(), z.number());
// Record<string, number>

Scores.parse({ alice: 92, bob: 87 });

// 固定 key 类型
const I18n = z.record(
  z.enum(["en", "zh", "ja"]),
  z.string()
);
// Record<"en" | "zh" | "ja", string>

z.map / z.set

z.map(z.string(), z.number());
// Map<string, number>

z.set(z.string());
// Set<string>

// 注意:输入必须是真的 Map / Set 实例,不是 plain object

z.lazy:递归类型

interface Category {
  id: string;
  name: string;
  children: Category[];
}

const CategorySchema: z.ZodType<Category> = z.lazy(() =>
  z.object({
    id: z.string(),
    name: z.string(),
    children: z.array(CategorySchema),
  })
);
递归类型的限制
z.lazy 需要你显式标注 TS 类型(: z.ZodType<Category>)——TS 不能自己推断无限递归。另外没法用 z.infer,只能自己声明。

z.function:函数签名

const addSchema = z
  .function()
  .args(z.number(), z.number())
  .returns(z.number());

const add = addSchema.parse((a: number, b: number) => a + b);
add(1, 2);
// Zod 自动在调用前校验 args,调用后校验 return

add("1" as any, 2);
// ZodError: args[0] expected number, received string

给第三方 callback 加运行时守门——比如允许用户传 plugin 函数时,校验它收到的参数和返回值。

z.promise

const schema = z.promise(z.string());
// Promise<string>

const p = schema.parse(Promise.resolve("ok"));
// 返回一个新的 Promise,会在 resolve 时校验值

optional vs nullable vs nullish vs default

用法接受 undefined接受 nullTS 类型
.optional()T | undefined
.nullable()T | null
.nullish()T | null | undefined
.default(v)✓ → 变 vT
.catch(v)错误时 → v错误时 → vT
z.string().catch("unknown");
// 校验失败不抛异常,返回 "unknown"
// 适合"有值最好,没有也行"的场景,比如可选的 tracking ID

readonly

const schema = z.array(z.number()).readonly();
type T = z.infer<typeof schema>;
// readonly number[]

// 运行时也 Object.freeze

brand:标称类型

const UserId = z.string().uuid().brand<"UserId">();
type UserId = z.infer<typeof UserId>;
// string & { [Symbol]: "UserId" }

const PostId = z.string().uuid().brand<"PostId">();
type PostId = z.infer<typeof PostId>;

function loadUser(id: UserId) { ... }
loadUser("uuid-xyz");     // ❌ TS error: 需要 UserId 不是 string
loadUser(PostId.parse("..."));  // ❌ TS error: PostId 不是 UserId
loadUser(UserId.parse("..."));  // ✓

brand 让"同样是 string 但语义不同"的 ID 在编译期就不能互相替换——防止把 PostId 误传给吃 UserId 的函数。

实战:API 响应类型

// 真实场景:后端统一包装成 Result<T, E>
const Ok = <T extends z.ZodTypeAny>(data: T) =>
  z.object({ status: z.literal("ok"), data });

const Err = z.object({
  status: z.literal("error"),
  code: z.string(),
  message: z.string(),
});

const UserResponse = z.discriminatedUnion("status", [
  Ok(UserSchema),
  Err,
]);

const res = UserResponse.parse(await fetch(...).then(r => r.json()));

if (res.status === "ok") {
  res.data;    // User, TS 窄化完成
} else {
  res.code;    // string
}

JSON schema 对应表

JSON 类型Zod
primitivez.string/number/boolean/null
arrayz.array(item)
tuplez.tuple([...])
objectz.object({...})
additionalPropertiesz.record(key, value)
oneOfz.discriminatedUnion / union
allOfz.intersection / .merge
enumz.enum([...])
constz.literal(...)

本章小结