Skip to content

联合与字面量

要点

  • 到目前为止我们写的 schema 都假设字段只有一种形态:name 是 string、age 是 number
  • z.literal(v) 要求数据必须严格等于 v
  • 当你有一组字符串候选值时,z.enum() 比写一堆 z.literal() 联合更自然
  • 如果你在项目里本来就有 TS enum,不用为了 Zod 重新写一遍
  • 前面几个方法都只能在「值」的层面联合

内容

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

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

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

NOTE

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

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

typescript
// index.ts
type Role = 'system' | 'user' | 'assistant'

type Value = string | number

type Message = TextMessage | ImageMessage | ToolCallMessage

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

typescript
// index.ts
z.literal('user')                       // 只能是字面量 'user'

z.enum(['system', 'user', 'assistant']) // 预定义字符串集合

z.nativeEnum(MyTsEnum)                  // 用已有 TS 枚举

z.union([schemaA, schemaB])             // 任意 schema 的联合

z.discriminatedUnion('type', [...])     // 带辨识字段的联合(推荐)

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

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

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

typescript
// index.ts
const schema = z.literal('user')

schema.parse('user')      // ✅ 'user'

schema.parse('User')      // 💥 大小写不一样也不行

schema.parse('admin')     // 💥

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

typescript
// index.ts
// 强制 role 必须是 'assistant'

const AssistantRole = z.literal('assistant')

// 标记某个对象的「类型字段」

const TextMessage = z.object({

  type: z.literal('text'),

  content: z.string(),

})

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

2.1 支持的字面量类型

typescript
// index.ts
z.literal('hello')    // 字符串

z.literal(42)         // 数字

z.literal(true)       // 布尔

z.literal(null)       // null

z.literal(undefined)  // undefined

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

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

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

typescript
// index.ts
const RoleSchema = z.enum(['system', 'user', 'assistant'])

RoleSchema.parse('user')      // ✅

RoleSchema.parse('admin')     // 💥

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

typescript
// index.ts
type Role = z.infer<typeof RoleSchema>

// 'system' | 'user' | 'assistant'

3.1 从已有数组创建:as const

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

typescript
// index.ts
const ROLES = ['system', 'user', 'assistant'] as const

const RoleSchema = z.enum(ROLES)

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

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

typescript
// index.ts
RoleSchema.enum

// { system: 'system', user: 'user', assistant: 'assistant' }

// 可以像访问对象一样用:RoleSchema.enum.user

RoleSchema.options

// ['system', 'user', 'assistant']

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

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

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

typescript
// index.ts
enum UserRole {

  Admin = 'admin',

  User = 'user',

  Guest = 'guest',

}

const RoleSchema = z.nativeEnum(UserRole)

RoleSchema.parse('admin')            // ✅

RoleSchema.parse(UserRole.Admin)     // ✅

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()

typescript
// index.ts
const IdSchema = z.union([z.string(), z.number()])

IdSchema.parse('abc')    // ✅

IdSchema.parse(123)      // ✅

IdSchema.parse(true)     // 💥

推导类型:

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

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

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

这导致两个问题:

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

举个例子:

typescript
// index.ts
const MessageSchema = z.union([

  z.object({ type: z.literal('text'), text: z.string() }),

  z.object({ type: z.literal('image'), url: z.string().url() }),

])

MessageSchema.parse({ type: 'image', url: 'not-a-url' })

// 💥 会报两段错误:

//   1. text 分支:type 不是 'text'

//   2. image 分支:url 不是合法 URL

// 你真正想看到的是第 2 条,但第 1 条会干扰

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

5.2 什么时候还是该用 union

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

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

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

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

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

json
// index.json
[

  { "type": "text",       "text": "Hello" },

  { "type": "image",      "url": "https://..." },

  { "type": "tool_call",  "name": "search", "args": { "q": "ai" } }

]

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

typescript
// message-schema.ts
const MessageSchema = z.discriminatedUnion('type', [

  z.object({

    type: z.literal('text'),

    text: z.string(),

  }),

  z.object({

    type: z.literal('image'),

    url: z.string().url(),

  }),

  z.object({

    type: z.literal('tool_call'),

    name: z.string(),

    args: z.record(z.unknown()),

  }),

])

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

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

6.1 它解决了 union 的两个问题

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

// 💥 错误只报:url Invalid url

// 不会再把 text 分支的错误混进来

6.2 类型推导也更智能

typescript
// index.ts
type Message = z.infer<typeof MessageSchema>

function render(msg: Message) {

  if (msg.type === 'text') {

    msg.text     // ✅ TS 知道这里一定有 text

  } else if (msg.type === 'image') {

    msg.url      // ✅ TS 知道这里一定有 url

  }

}

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

6.3 访问所有分支

typescript
// index.ts
MessageSchema.options

// 返回每个分支的 schema 数组,可用于文档生成、动态 UI 等

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

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

typescript
// chat-schema.ts
import { z } from 'zod'

// 辨识字段:role

const SystemMessage = z.object({

  role: z.literal('system'),

  content: z.string(),

})

const UserMessage = z.object({

  role: z.literal('user'),

  content: z.union([

    z.string(),                                         // 纯文本

    z.array(z.discriminatedUnion('type', [              // 多模态内容

      z.object({ type: z.literal('text'),  text: z.string() }),

      z.object({ type: z.literal('image'), url: z.string().url() }),

    ])),

  ]),

})

const AssistantMessage = z.object({

  role: z.literal('assistant'),

  content: z.string().nullable(),

  toolCalls: z.array(z.object({

    id: z.string(),

    name: z.string(),

    args: z.record(z.unknown()),

  })).optional(),

})

const ToolMessage = z.object({

  role: z.literal('tool'),

  toolCallId: z.string(),

  content: z.string(),

})

// 顶层:按 role 辨识

export const MessageSchema = z.discriminatedUnion('role', [

  SystemMessage,

  UserMessage,

  AssistantMessage,

  ToolMessage,

])

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
// text
要校验的是「几选一」吗?

├── 是一个字符串枚举 → z.enum([...])

├── 是已有的 TS enum → z.nativeEnum(E)

├── 是几个结构不同的对象

│    └── 它们有共享的 type/role/kind 字段吗?

│         ├── 有 → z.discriminatedUnion('type', [...]) ✅

│         └── 没有 → z.union([...])(并考虑加一个辨识字段)

└── 单个字面量 → z.literal(v)

一句话带走:

NOTE

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

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

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

基于 MIT 协议开源