AI 电子伴侣
创建时间: 2026-04-18最后更新: 2026-04-18

1. 为什么 AI 项目特别需要「联合」

到目前为止我们写的 schema 都假设字段只有一种形态name 是 string、age 是 number。

但真实世界——尤其是 AI 项目——经常不是这样。消息可能有 4 种角色,工具调用可能有 5 种类型,模型返回的 payload 可能有 3 种形状。这些字段共同的特征是:

NOTE

「它不是某一个固定值,而是有限的几种中的一个。」

这就是联合类型(Union)。在 TypeScript 里你已经见过很多次:

index.ts
1
type Role = 'system' | 'user' | 'assistant'
2
type Value = string | number
3
type Message = TextMessage | ImageMessage | ToolCallMessage

Zod 里一共有 5 个工具来表达这类「多选一」:

index.ts
1
z.literal('user') // 只能是字面量 'user'
2
z.enum(['system', 'user', 'assistant']) // 预定义字符串集合
3
z.nativeEnum(MyTsEnum) // 用已有 TS 枚举
4
z.union([schemaA, schemaB]) // 任意 schema 的联合
5
z.discriminatedUnion('type', [...]) // 带辨识字段的联合(推荐)

这一篇我们按从简单到复杂的顺序一个个过,最后给一张选型表。

2. z.literal():锁定一个具体值

z.literal(v) 要求数据必须严格等于 v

index.ts
1
const schema = z.literal('user')
2
3
schema.parse('user') // ✅ 'user'
4
schema.parse('User') // 💥 大小写不一样也不行
5
schema.parse('admin') // 💥

它看起来没什么用——「就一个值,有什么好校验的」?但它和别的方法组合起来会非常有用:

index.ts
1
// 强制 role 必须是 'assistant'
2
const AssistantRole = z.literal('assistant')
3
4
// 标记某个对象的「类型字段」
5
const TextMessage = z.object({
6
type: z.literal('text'),
7
content: z.string(),
8
})

后面讲 discriminatedUnion 时你会发现,literal 是它的核心零件

2.1 支持的字面量类型

index.ts
1
z.literal('hello') // 字符串
2
z.literal(42) // 数字
3
z.literal(true) // 布尔
4
z.literal(null) // null
5
z.literal(undefined) // undefined

字符串、数字、布尔、nullundefined 都行。对象和数组不行(那些用 z.object / z.array 就够)。

3. z.enum():一组字符串字面量

当你有一组字符串候选值时,z.enum() 比写一堆 z.literal() 联合更自然:

index.ts
1
const RoleSchema = z.enum(['system', 'user', 'assistant'])
2
3
RoleSchema.parse('user') // ✅
4
RoleSchema.parse('admin') // 💥

推导出的 TS 类型是精确的联合字面量:

index.ts
1
type Role = z.infer<typeof RoleSchema>
2
// 'system' | 'user' | 'assistant'

3.1 从已有数组创建:as const

如果候选值定义在别的地方(比如配置文件里),记得加 as const

index.ts
1
const ROLES = ['system', 'user', 'assistant'] as const
2
const RoleSchema = z.enum(ROLES)

少了 as const,TS 会把数组类型推导成 string[],Zod 就拿不到精确的字面量了。这是新手很容易踩的一个小坑。

3.2 访问候选值:.enum 和 .options

index.ts
1
RoleSchema.enum
2
// { system: 'system', user: 'user', assistant: 'assistant' }
3
// 可以像访问对象一样用:RoleSchema.enum.user
4
5
RoleSchema.options
6
// ['system', 'user', 'assistant']

这两个属性在「动态渲染选项」的场景很有用——比如前端下拉框、接口文档生成。

4. z.nativeEnum():搭配 TS / JS 原生枚举

如果你在项目里本来就有 TS enum,不用为了 Zod 重新写一遍:

index.ts
01
enum UserRole {
02
Admin = 'admin',
03
User = 'user',
04
Guest = 'guest',
05
}
06
07
const RoleSchema = z.nativeEnum(UserRole)
08
09
RoleSchema.parse('admin') // ✅
10
RoleSchema.parse(UserRole.Admin) // ✅
11
RoleSchema.parse('root') // 💥

什么时候用 nativeEnum vs enum

场景选择
新代码,没有历史 enumz.enum([...]) — 更简单
项目里已经有 TS enumz.nativeEnum(MyEnum) — 避免重复定义
数值枚举(enum 成员是 number)只能用 z.nativeEnum

日常新项目 90% 用 z.enum([...]) 就够了。

5. z.union():任意 schema 的联合

前面几个方法都只能在「值」的层面联合。要联合结构不同的对象,就得上 z.union()

index.ts
1
const IdSchema = z.union([z.string(), z.number()])
2
3
IdSchema.parse('abc') // ✅
4
IdSchema.parse(123) // ✅
5
IdSchema.parse(true) // 💥

推导类型:

index.ts
1
type Id = z.infer<typeof IdSchema> // string | number

5.1 union 的工作方式:依次尝试

z.union([A, B, C]) 的校验逻辑是:依次用 A、B、C 校验输入,任何一个通过就通过,全部失败才算失败。

这导致两个问题:

  1. 性能:schema 越多越慢,每次都要串行尝试
  2. 错误信息混乱:失败时会把所有分支的错误都列出来,很难定位到底是哪一个 schema 最接近

举个例子:

index.ts
01
const MessageSchema = z.union([
02
z.object({ type: z.literal('text'), text: z.string() }),
03
z.object({ type: z.literal('image'), url: z.string().url() }),
04
])
05
06
MessageSchema.parse({ type: 'image', url: 'not-a-url' })
07
// 💥 会报两段错误:
08
// 1. text 分支:type 不是 'text'
09
// 2. image 分支:url 不是合法 URL
10
// 你真正想看到的是第 2 条,但第 1 条会干扰

所以实际写对象联合时,更推荐下一节的 discriminatedUnion

5.2 什么时候还是该用 union

z.union() 在下面这些场景仍然是首选:

  • 联合的是基础类型(如 string | number),没有可用的辨识字段
  • 联合的 schema 里只有部分是对象,混着基础类型
  • 联合的对象之间没有共享的字面量字段(这种设计其实多半有问题,但有时候确实遇到)

换句话说:只要你联合的是结构不同但有共同标识字段的对象,就别用 union,用 discriminatedUnion。

6. z.discriminatedUnion():AI 项目的大杀器

如果你写过一个稍微复杂点的 AI 应用,一定见过这类数据:

index.json
1
[
2
{ "type": "text", "text": "Hello" },
3
{ "type": "image", "url": "https://..." },
4
{ "type": "tool_call", "name": "search", "args": { "q": "ai" } }
5
]

这些对象的结构完全不同,但共享一个辨识字段 type。这正是 discriminatedUnion 设计的场景:

message-schema.ts
01
const MessageSchema = z.discriminatedUnion('type', [
02
z.object({
03
type: z.literal('text'),
04
text: z.string(),
05
}),
06
z.object({
07
type: z.literal('image'),
08
url: z.string().url(),
09
}),
10
z.object({
11
type: z.literal('tool_call'),
12
name: z.string(),
13
args: z.record(z.unknown()),
14
}),
15
])

第一个参数是辨识字段名,第二个参数是各个分支的 schema。每个分支都必须:

  1. z.object
  2. 含一个z.literal 声明的辨识字段(或 z.enum

6.1 它解决了 union 的两个问题

  1. 性能好:只需要读一次 input.type,就能直接跳到对应分支,不用串行试
  2. 错误精准:校验失败时,Zod 知道你本意'image' 分支,错误信息会直接指向该分支的具体字段
index.ts
1
MessageSchema.parse({ type: 'image', url: 'not-a-url' })
2
// 💥 错误只报:url Invalid url
3
// 不会再把 text 分支的错误混进来

6.2 类型推导也更智能

index.ts
1
type Message = z.infer<typeof MessageSchema>
2
3
function render(msg: Message) {
4
if (msg.type === 'text') {
5
msg.text // ✅ TS 知道这里一定有 text
6
} else if (msg.type === 'image') {
7
msg.url // ✅ TS 知道这里一定有 url
8
}
9
}

这正是 TypeScript 可辨识联合(Discriminated Union) 的典型用法——schema 和类型两头都能吃上。

6.3 访问所有分支

index.ts
1
MessageSchema.options
2
// 返回每个分支的 schema 数组,可用于文档生成、动态 UI 等

7. 实战:一个真实的 AI 消息结构

我们把这一篇学到的东西组合起来,写一份接近真实项目的消息 schema:

chat-schema.ts
01
import { z } from 'zod'
02
03
// 辨识字段:role
04
const SystemMessage = z.object({
05
role: z.literal('system'),
06
content: z.string(),
07
})
08
09
const UserMessage = z.object({
10
role: z.literal('user'),
11
content: z.union([
12
z.string(), // 纯文本
13
z.array(z.discriminatedUnion('type', [ // 多模态内容
14
z.object({ type: z.literal('text'), text: z.string() }),
15
z.object({ type: z.literal('image'), url: z.string().url() }),
16
])),
17
]),
18
})
19
20
const AssistantMessage = z.object({
21
role: z.literal('assistant'),
22
content: z.string().nullable(),
23
toolCalls: z.array(z.object({
24
id: z.string(),
25
name: z.string(),
26
args: z.record(z.unknown()),
27
})).optional(),
28
})
29
30
const ToolMessage = z.object({
31
role: z.literal('tool'),
32
toolCallId: z.string(),
33
content: z.string(),
34
})
35
36
// 顶层:按 role 辨识
37
export const MessageSchema = z.discriminatedUnion('role', [
38
SystemMessage,
39
UserMessage,
40
AssistantMessage,
41
ToolMessage,
42
])
43
44
export type Message = z.infer<typeof MessageSchema>

这份 schema 做到了三件事:

  1. 顶层用 role 作为辨识字段区分 4 种消息类型
  2. UserMessagecontent 又是一个联合:纯文本多模态内容数组
  3. content 的多模态数组内部,又是一层按 type 辨识的 discriminatedUnion

复杂吗?看起来挺复杂。但每一层都只是前面学过的三块积木的组合:object、array、discriminatedUnion。

这正是 Zod 的美感——小积木能搭出任意复杂的真实数据结构,而且类型一路推导到底

8. 总结

这一篇我们把 Zod 里表达「多选一」的 5 个工具都过了一遍:

方法用于典型场景
z.literal(v)锁定单个字面量值作为 discriminator 的零件
z.enum([...])一组字符串字面量role、status、level 这类固定集合
z.nativeEnum(E)已有 TS / JS 枚举和 enum 定义集成
z.union([...])通用联合(任何 schema)基础类型联合 / 无共享字段的对象
z.discriminatedUnion(k, [...])带辨识字段的对象联合消息、事件、工具调用(AI 项目最常用

一张用于日后选型的决策图:

text
1
要校验的是「几选一」吗?
2
├── 是一个字符串枚举 → z.enum([...])
3
├── 是已有的 TS enum → z.nativeEnum(E)
4
├── 是几个结构不同的对象
5
│ └── 它们有共享的 type/role/kind 字段吗?
6
│ ├── 有 → z.discriminatedUnion('type', [...]) ✅
7
│ └── 没有 → z.union([...])(并考虑加一个辨识字段)
8
└── 单个字面量 → z.literal(v)

一句话带走:

NOTE

一旦你在 schema 里看到「几种对象形态之一」,反射弧应该是 discriminatedUnion,而不是 union。

到这里,Zod 入门篇就全部完结了。你现在已经有能力写出真实项目里 90% 的业务 schema。

从下一篇开始我们进入进阶篇:从 refine / superRefine 开始,讲 Zod 真正让人爱上的那部分能力——不仅描述数据形状,还描述数据之间的关系和业务规则