TypeScript 运行时类型安全边界:Zod 深度、Valibot 对比与 API 契约

构建运行时类型安全边界:Zod 判别联合/transform/refine/效果、Valibot 对比选型、API 输入输出契约建模,以及 schema 与类型体操的深度结合。

编译期类型再严谨,也无法保证"越过进程边界"的数据是安全的——HTTP 请求体、数据库行、消息队列里的消息,在运行时都可能与声明不符。这就是运行时类型安全边界(Runtime Type Safety Boundary)存在的意义:在数据进入可信域的那一刻,用 schema 做一次"诚实检查"。

本文是 https://plumephp.com/typescript-zod-validation/ 的进阶,聚焦三件事:Zod 的高级建模能力(判别联合、transform、refine、effects)、Valibot 对比与选型、以及把 schema 当作 API 契约 + 与类型体操深度结合。


1. Zod 深度建模

1.1 判别联合(Discriminated Union)

业务状态机(订单状态、事件类型)是运行时校验最典型的高价值场景。z.discriminatedUnion 用判别字段精确区分联合分支,错误信息会定位到具体分支:

import { z } from 'zod';

const OrderEvent = z.discriminatedUnion('type', [
  z.object({
    type: z.literal('created'),
    orderId: z.string().uuid(),
    at: z.coerce.date(),
  }),
  z.object({
    type: z.literal('paid'),
    orderId: z.string().uuid(),
    amount: z.number().positive(),
    paymentMethod: z.enum(['wechat', 'alipay', 'card']),
  }),
  z.object({
    type: z.literal('cancelled'),
    orderId: z.string().uuid(),
    reason: z.string().min(1).optional(),
  }),
]);

type OrderEvent = z.infer<typeof OrderEvent>;
// => 精确的判别联合,type 字段自动收窄
// 消费时享受判别收窄:TS 根据 type 自动推断其余字段
function handleEvent(event: OrderEvent) {
  switch (event.type) {
    case 'created':
      event.orderId; // 存在
      event.amount;  // 编译错误:created 分支没有 amount
      break;
    case 'paid':
      event.amount; // 存在,且是 number
      break;
  }
}

对比普通的 z.union:discriminatedUnion 校验速度更快(直接读 type 字段定位分支),且每个分支的错误不混淆。

1.2 transform 与链式转换

transform 让 schema 具备"校验 → 转换"两段能力。z.infer 取到的是 transform 之后的输出类型,这是"输入松散、输出严格"的关键:

const PointSchema = z.object({
  x: z.coerce.number(),
  y: z.coerce.number(),
}).transform(({ x, y }) => ({ x: Math.round(x), y: Math.round(y) }));

// z.input 是转换前的类型,z.output 是转换后的类型
type PointInput = z.input<typeof PointSchema>;  // { x: number; y: number }
type PointOutput = z.output<typeof PointSchema>; // { x: number; y: number }

// 更常用的转换:字符串 → 业务对象
const DateRangeSchema = z.object({
  from: z.string().datetime(),
  to: z.string().datetime(),
}).transform(({ from, to }) => ({
  from: new Date(from),
  to: new Date(to),
  days: Math.round((new Date(to).getTime() - new Date(from).getTime()) / 86_400_000),
}));

transform 链可以多段组合,配合 pipe 可以做到"先校验、再转换、再校验":

const CsvSchema = z
  .string()
  .transform((s) => s.split(',').map((n) => Number(n)))
  .pipe(z.array(z.number().int().nonnegative())); // 转换后二次校验

1.3 refine / superRefine:跨字段业务校验

refine 处理"字段级难以表达的跨字段约束",superRefine 更进一步提供精确的路径化错误:

const BookingSchema = z
  .object({
    checkIn: z.coerce.date(),
    checkOut: z.coerce.date(),
    roomCount: z.number().int().min(1).max(5),
  })
  .refine((b) => b.checkOut > b.checkIn, {
    message: 'checkOut 必须晚于 checkIn',
    path: ['checkOut'], // 错误定位到具体字段
  });

superRefine 可以用 ctx.addIssue 追加多条错误,适合复杂规则:

const OrderSchema = z.object({
  items: z.array(z.object({ sku: z.string(), qty: z.number().int().min(1) })),
  coupon: z.string().optional(),
}).superRefine((data, ctx) => {
  // 规则 1:无商品
  if (data.items.length === 0) {
    ctx.addIssue({ code: 'custom', message: '订单至少包含一件商品', path: ['items'] });
  }
  // 规则 2:使用优惠券但金额不足
  if (data.coupon && data.items.reduce((s, i) => s + i.qty, 0) < 3) {
    ctx.addIssue({ code: 'custom', message: '数量不满 3 件不可用优惠券', path: ['coupon'] });
  }
});

2. Zod Effects 与 Schema 组合

2.1 preprocess:在校验之前预处理

z.preprocess 在校验之前对原始值做任意转换,比 coerce 更自由(比如解析 JSON、Trim 字符串):

const QuerySchema = z.object({
  raw: z.preprocess((v) => {
    if (typeof v === 'string') {
      try { return JSON.parse(v); } catch { return v; }
    }
    return v;
  }, z.object({ page: z.number(), size: z.number() })),
});

2.2 递归 Schema:树形数据

树形数据(分类、评论嵌套、AST)需要递归 schema。z.lazy 是官方方案:

interface CategoryNode {
  id: string;
  name: string;
  children?: CategoryNode[];
}

const CategorySchema: z.ZodType<CategoryNode> = z.lazy(() =>
  z.object({
    id: z.string(),
    name: z.string().min(1),
    children: z.array(CategorySchema).optional(),
  }),
);

type Category = z.infer<typeof CategorySchema>;

z.ZodType<CategoryNode> 作为类型注解打破循环引用,lazy 延迟求值直到运行时。

2.3 从 Schema 推导复杂类型:Schema → Type

Zod 的核心哲学是"一份 schema,双向类型"。复杂 schema 的推断能力让 z.infer 成为类型体操的天然来源:

const ApiResponseSchema = z.object({
  code: z.number().int(),
  data: z.discriminatedUnion('kind', [
    z.object({ kind: z.literal('user'), user: z.object({ id: z.string(), name: z.string() }) }),
    z.object({ kind: z.literal('list'), items: z.array(z.string()) }),
  ]),
  meta: z.object({ requestId: z.string().uuid() }).optional(),
});

type ApiResponse = z.infer<typeof ApiResponseSchema>;
// 自动得到精确的响应类型,无需手写接口

这是运行时类型安全边界的第一个支柱:单一来源(Single Source of Truth)——接口定义与运行时校验永远同步,不会出现"类型说 A、校验器验 B"。


3. Valibot 对比与选型

3.1 体积与 Tree-shaking

Valibot 最大的卖点是模块化与 tree-shaking 友好:每个校验器是一个独立函数,打包时只保留用到的部分。对比数据(理论值):

维度ZodValibot
全量体积(min)~13KB+(未 tree-shake 时更大)基础 ~1KB,按需引入
Tree-shaking整体库不可摇每个 schema 独立可摇
API 风格链式 z.object().refine()函数组合 object({...}) + 独立校验器
错误对象z.ZodError(结构化 issues)ValiError(issues 数组)
类型推断z.infer / z.inputInput / Output 类型工具
依赖零依赖零依赖
import * as v from 'valibot';

const UserSchema = v.object({
  id: v.number(),
  name: v.string([v.minLength(1), v.maxLength(100)]),
  email: v.pipe(v.string(), v.email()),
  role: v.union([v.literal('user'), v.literal('admin')]),
});

type User = v.Output<typeof UserSchema>;
const result = v.safeParse(UserSchema, unknownData);
if (result.success) {
  result.output; // 类型为 User
}

注意 API 差异:Valibot 的约束是数组参数 v.string([v.minLength(1)]),而不是链式;转换用 v.pipe(),相当于 Zod 的 pipe。

3.2 能力对照:核心特性

特性ZodValibot
判别联合discriminatedUnionvariant(等价的)
转换transform / pipepipe + transform
复杂自定义错误superRefine + addIssuecheck + addIssue
递归 schemaz.lazyrecursive
Schema 元数据z.description 等metadata
生态最广(tRPC、react-hook-form、trpc)渐增

3.3 选型建议

  • 追求最小包体(组件库、边缘函数、小程序):选 Valibot,只引入用到的校验器。
  • 生态优先、团队熟悉、复杂业务规则多:选 Zod,生态工具链最完整(tRPC 默认、react-hook-form 集成、Prisma 生态)。
  • 两者可共存:边界清晰时混合使用并无不可,但同一项目内尽量统一,避免心智负担。

实际体积影响要在真实打包中验证——下面用 bundlesize 思路做参考:

# 参考:仅打包一个 object + string 校验
npx esbuild --bundle --minify main.ts   # Zod vs Valibot 各测一次,对比产物字节

4. API 输入输出契约

4.1 请求 / 响应 schema 契约

类型安全边界的第二个支柱:每个 API 端点都有明确的输入/输出 schema,形成契约:

// contracts.ts —— 端点契约集中定义
import { z } from 'zod';

export const CreateUserInput = z.object({
  name: z.string().min(1).max(50),
  email: z.string().email(),
  password: z.string().min(8).max(72),
  role: z.enum(['user', 'admin']).default('user'),
});

export const UserOutput = z.object({
  id: z.string().uuid(),
  name: z.string(),
  email: z.string().email(),
  role: z.enum(['user', 'admin']),
  createdAt: z.iso.datetime(), // 或 z.string().datetime()
});

export type CreateUserInput = z.infer<typeof CreateUserInput>;
export type UserOutput = z.infer<typeof UserOutput>;

客户端与服务器共享同一份 contracts(monorepo 中放共享包),就获得了端到端类型安全:

// 客户端:请求体由输入 schema 推导,响应由输出 schema 校验
async function createUser(input: CreateUserInput): Promise<UserOutput> {
  const res = await fetch('/api/users', {
    method: 'POST',
    body: JSON.stringify(input),
  });
  const data = await res.json();
  return UserOutput.parse(data); // 运行时验证响应
}

4.2 与服务端框架集成

在 Fastify 中,schema 直接绑定路由的 schema 选项;在 NestJS 中用 ValidationPipe:

// Fastify + 共享契约
import { CreateUserInput, UserOutput } from '@contracts/user';

server.post('/api/users', {
  schema: {
    body: CreateUserInput,   // Fastify 内置校验 + 序列化
    response: { 201: UserOutput },
  },
  handler: async (req, reply) => {
    const input = req.body; // 已被校验并类型化为 CreateUserInput
    // ...
  },
});
// NestJS + zod 校验管道(配合 class-validator 之外的替代方案)
// 在控制器方法上用 zod guard 包装
async create(@Body() raw: unknown): Promise<UserOutput> {
  const input = CreateUserInput.parse(raw);
  // ...
}

4.3 契约测试:双向验证

契约不是写出来就完事,需要双向测试——既验证服务端"拒绝了坏输入",也验证"输出符合 schema":

// 契约测试(vitest 风格)
import { describe, it, expect } from 'vitest';

it('服务端拒绝非法 email', async () => {
  const res = await fetch('/api/users', {
    method: 'POST',
    body: JSON.stringify({ name: 'x', email: 'not-an-email', password: '12345678' }),
  });
  expect(res.status).toBe(400);
  const body = await res.json();
  expect(body.issues?.[0].path).toContain('email');
});

it('服务端响应符合 UserOutput', async () => {
  const res = await fetch('/api/users/123');
  const data = await res.json();
  expect(() => UserOutput.parse(data)).not.toThrow(); // 输出契约
});

当 schema 变更时,契约测试让"服务端与客户端不同步"在 CI 里立刻暴露。


5. 与类型体操的深度结合

5.1 从 Schema 推导出边界类型

运行时校验与类型体操结合的最高价值形态:用类型体操把 schema 的能力"前移"到编译期。例如,从 schema 推导出"只读的输入类型":

import { z } from 'zod';

const EnvSchema = z.object({
  DATABASE_URL: z.string().min(1),
  JWT_SECRET: z.string().min(16),
  LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
});

type Env = z.infer<typeof EnvSchema>;

// 类型体操:把 Env 变成"部分配置 + 只读"
type DeepReadonly<T> = { readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K] };
type EnvTemplate = DeepReadonly<Partial<Env>>;

// 用途:在测试里只覆盖需要覆盖的字段,其余用默认
const testEnv: EnvTemplate = { LOG_LEVEL: 'debug' };

5.2 输入输出一致性检查

用类型工具强制"请求类型必须与校验 schema 输入一致":

type InputOf<S> = S extends z.ZodType<infer Out, infer Def, infer In> ? In : never;
type OutputOf<S> = S extends z.ZodType<infer Out> ? Out : never;

// 编译期断言:契约的输入输出类型与推导一致
type AssertEqual<A, B, M extends string> = (<T>() => T extends A ? 1 : 2) extends
  (<T>() => T extends B ? 1 : 2) ? true : M;

type _Check = AssertEqual<
  OutputOf<typeof UserOutput>,
  { id: string; name: string; email: string; role: 'user' | 'admin'; createdAt: string },
  'UserOutput 与期望类型不一致'
>;

这类断言让"schema 变了但类型假设没更新"的问题在编译期暴露。

5.3 让 schema 驱动表单类型

前端表单是运行时校验的经典场景——用 schema 推导表单状态类型,配合 react-hook-form 获得双向类型:

const LoginFormSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
  remember: z.boolean().default(false),
});

type LoginFormValues = z.input<typeof LoginFormSchema>;  // 表单原始值
type LoginFormResult = z.output<typeof LoginFormSchema>; // 校验后的值

// 使用 zodResolver 接入 react-hook-form
// const { register, handleSubmit } = useForm<LoginFormValues>({
//   resolver: zodResolver(LoginFormSchema),
// });

6. 最佳实践清单

场景推荐 schema 手法
事件 / 状态机discriminatedUnion,享受判别收窄
外部 API 响应parse 严格校验(失败即抛,无法静默通过)
用户输入容错safeParse + 错误收集(UI 展示)
字符串 → 日期/数字coerce 或 preprocess + transform
跨字段规则superRefine + addIssue(路径化错误)
树形数据lazy / recursive
打包体积敏感评估 Valibot

三条核心原则:

  1. 边界越少越安全:只在"进入可信域"的边界校验(API 入口、队列消费、DB 写前),不要在每行代码里重复 parse。
  2. 单一来源:schema 是类型与校验的唯一真源,不要手写与之并行的 interface。
  3. 契约要可测:每个边界配契约测试,schema 变更进 CI。

想深入 schema 与类型系统的组合能力,可参考 https://plumephp.com/typescript-type-level-programming/;把运行时校验放入大型项目的模块边界,可参考 https://plumephp.com/typescript-project-architecture-tsconfig/;在 Node 后端(Fastify/NestJS)中落地校验管道与错误处理,可参考 https://plumephp.com/typescript-nodejs-backend/ 与 https://plumephp.com/typescript-async-concurrency-control/。


7. 总结

运行时类型安全边界解决的是**“编译期信任无法覆盖运行时数据”**这一根本问题。Zod 的高级建模(判别联合、transform、superRefine、lazy 递归)把业务规则编码进 schema;Valibot 提供体积敏感场景的替代;而"契约共享 + 契约测试 + 类型体操断言"则让边界成为可维护的工程资产。

真正的类型安全 = 编译期类型(静态) × 运行时 schema(动态),两者在边界上精确对齐。把校验放在该放的地方、让 schema 成为唯一真源、用测试锁住契约——你的系统在任何"不可信数据"面前都保持诚实。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「frontend」更多文章

  1. CSS 架构与样式方案:从方法论到现代 CSS 新特性
  2. 可访问性与国际化:WCAG 2.2、ARIA 与 i18n 工程实践
  3. SSR/SSG 渲染模式全景:Next.js App Router、流式渲染与岛屿架构