Chapter 10

从学会到真正用好

Zod 简单,但真正用好需要一些"边界感"——在哪儿加校验、哪儿别加、schema 怎么组织、常见陷阱怎么避。这一章是把前 9 章学到的东西编织进生产项目的最后一环。

守门员模式:在系统边界校验

Zod 最重要的使用原则:在数据进入系统的地方校验,不在内部代码到处加

// 系统边界(要加)
// ✓ 入口:HTTP / GraphQL / CLI args / env / 表单 / 文件上传
// ✓ 出口:发给外部 API 的 payload / 写入消息队列
// ✓ 持久层:localStorage / cookie / DB 输出反序列化

// 内部代码(不要加)
// ✗ function 参数(TS 已经保护)
// ✗ 内部对象传递(已经是 User 类型了)
// ✗ 每层都 parse 一遍

防御式校验在边界做一次就够了——TS 保证内部代码拿到的就是校验过的类型。

环境变量:启动即校验

// src/env.ts
import { z } from "zod";

const envSchema = z.object({
  NODE_ENV: z.enum(["development", "production", "test"]),
  DATABASE_URL: z.string().url(),
  REDIS_URL: z.string().url(),
  PORT: z.coerce.number().int().min(1).max(65535).default(3000),
  OPENAI_API_KEY: z.string().startsWith("sk-"),
  STRIPE_SECRET: z.string().startsWith("sk_"),
  SENTRY_DSN: z.string().url().optional(),
});

const parsed = envSchema.safeParse(process.env);

if (!parsed.success) {
  console.error("❌ 环境变量错误:");
  console.error(parsed.error.flatten().fieldErrors);
  process.exit(1);
}

export const env = parsed.data;

main.ts 最开头引入 ./env。启动时发现 env 错直接崩,比运行半小时后报 500 友好一万倍。

// 也可以用 @t3-oss/env-core 封装得更优雅
import { createEnv } from "@t3-oss/env-core";

export const env = createEnv({
  server: { DATABASE_URL: z.string().url() },
  client: { NEXT_PUBLIC_APP_URL: z.string().url() },
  runtimeEnv: process.env,
});

LLM 输出:结构化解析

const Classification = z.object({
  category: z.enum(["bug", "feature", "question", "other"]),
  priority: z.enum(["low", "medium", "high"]),
  confidence: z.number().min(0).max(1),
  reasoning: z.string(),
});

const { object } = await generateObject({
  model: openai("gpt-4o"),
  schema: Classification,
  prompt: `分类这条 issue: ${issue.body}`,
});

// object 完全符合 schema —— OpenAI 会反复重试直到合法

LLM 时代 Zod 有了新作用:给模型约束输出形状。SDK 把 schema 转成 JSON Schema 喂给模型,模型保证输出符合。

缓存反序列化

const CachedUser = z.object({
  id: z.string(),
  name: z.string(),
  updatedAt: z.coerce.date(),   // Redis 里 Date 变字符串,解析回 Date
});

async function getUser(id: string) {
  const cached = await redis.get(`user:${id}`);

  if (cached) {
    const res = CachedUser.safeParse(JSON.parse(cached));
    if (res.success) return res.data;
    // 缓存结构变了(schema 升级) → 忽略缓存,重新查
    await redis.del(`user:${id}`);
  }

  const user = await db.user.findUnique({ where: { id } });
  await redis.set(`user:${id}`, JSON.stringify(user));
  return user;
}

用 safeParse 而不是 parse——缓存污染不该让主流程崩,降级到 DB 查即可。

localStorage 迁移

const SettingsV1 = z.object({
  theme: z.enum(["light", "dark"]),
});

const SettingsV2 = z.object({
  theme: z.enum(["light", "dark", "system"]),   // 新增 system
  fontSize: z.number().default(14),                        // 新增字段
});

function loadSettings() {
  const raw = localStorage.getItem("settings");
  if (!raw) return SettingsV2.parse({});   // 默认值

  const parsed = JSON.parse(raw);

  // 先试 v2
  const v2 = SettingsV2.safeParse(parsed);
  if (v2.success) return v2.data;

  // 再试 v1,迁移
  const v1 = SettingsV1.safeParse(parsed);
  if (v1.success) {
    return SettingsV2.parse({ ...v1.data, fontSize: 14 });
  }

  return SettingsV2.parse({});
}

schema 目录组织

src/
├─ schemas/
│  ├─ user.ts         # UserRow, CreateUser, UpdateUser, PublicUser
│  ├─ post.ts
│  ├─ comment.ts
│  ├─ api/            # HTTP 边界的 schema
│  │  ├─ sign-up.ts
│  │  └─ sign-in.ts
│  ├─ llm/            # LLM 输出 schema
│  │  └─ classification.ts
│  ├─ env.ts
│  └─ index.ts        # 统一导出
├─ server/
└─ client/

原则:

陷阱 1:用 parse 当控制流

// ❌ 错
try {
  const data = schema.parse(input);
} catch (e) {
  // 用异常控制流
}

// ✓ 对
const res = schema.safeParse(input);
if (!res.success) { ... }

parse 抛异常的性能开销比 safeParse 高不少,而且 catch 会遮住意外的非 Zod 异常。

陷阱 2:z.coerce.boolean 的陷阱

// ❌
z.coerce.boolean().parse("false");
// → true (!) 因为 Boolean("false") === true

// ✓
z.enum(["true", "false"]).transform((s) => s === "true").parse("false");
// → false

陷阱 3:z.date 不吃日期字符串

z.date().parse("2026-05-06");
// ❌ ZodError: 期望 Date 实例

z.coerce.date().parse("2026-05-06");
// ✓ Date

z.iso.date().parse("2026-05-06");
// ✓ 但返回的是字符串(不是 Date)

JSON 传输场景用 z.iso.date(stays string);要真 Date 实例用 z.coerce.date

陷阱 4:strict 模式的连锁反应

const UserV1 = z.object({ id: z.string() }).strict();

UserV1.parse({ id: "1", newField: "x" });
// ZodError: Unrecognized key "newField"
// 后端加了字段 → 老前端全挂

API 响应 schema 一般不要用 strict,用默认的 strip(丢掉未知字段)。只有入参严控才用 strict。

陷阱 5:schema 链式调用顺序

// ❌ min 在 transform 之后
z.string().transform((s) => s.trim()).min(1);
// min 检查的是 transform 之前的长度 —— 空格不会被 trim 掉再算

// ✓ 用 pipe
z.string().transform((s) => s.trim()).pipe(z.string().min(1));

// ✓ 或先 trim
z.string().trim().min(1);

陷阱 6:async schema 用同步 parse

const schema = z.string().refine(async ...);

schema.parse(x);       // ❌ 抛错:Async schema needs parseAsync
await schema.parseAsync(x);    // ✓

陷阱 7:大对象 parse 成本

// 100k 字段的对象每次 parse 慢
// ——只在入口校验一次,不要每个 API 层都 parse 一遍

// 大数组可以抽样校验
const sample = items.slice(0, 10);
z.array(Item).parse(sample);   // 只校验前 10 个
// 剩下的做 best-effort 处理

陷阱 8:z.any / z.unknown 的使用

z.any();       // 完全放弃校验,慎用
z.unknown();   // 类型是 unknown,至少后续要用前强制 narrow

// 更好:具体列出接受的形状
z.union([z.string(), z.number(), z.object({...})]);

陷阱 9:optional vs undefined vs nullable

// 常见混淆
z.string().optional()   // string | undefined —— "字段可不传"
z.string().nullable()   // string | null      —— "字段必传但可为 null"
z.string().nullish()    // 两者都接受

// API 设计建议:
// - 表单字段用 optional(未填就不传)
// - DB nullable 列用 nullable(明确存 null)
// - 不要滥用 nullish —— "两者都行"通常意味着 API 设计不清晰

性能优化

测试

import { UserRow } from "./schemas/user";

describe("UserRow schema", () => {
  test("accepts valid user", () => {
    expect(() => UserRow.parse({
      id: crypto.randomUUID(),
      email: "a@b.com",
      name: "A",
    })).not.toThrow();
  });

  test("rejects short name", () => {
    const res = UserRow.safeParse({ ..., name: "" });
    expect(res.success).toBe(false);
    expect(res.error.issues[0].path).toEqual(["name"]);
  });
});

schema 是一类"值",可以被单元测试——别让"业务规则"只存在脑子里。关键校验(密码规则、跨字段)一定要有测试。

把 schema 变文档

const User = z.object({
  id: z.string().uuid().describe("用户唯一 ID"),
  email: z.string().email().describe("登录邮箱,接收通知"),
  name: z.string().describe("显示名"),
}).describe("注册用户");

describe 的内容被 zod-to-openapi 转成 Swagger description,前端文档、后端代码、校验逻辑一份源

生产检查清单

终章回顾

从第 1 章"TS 到不了的地方",到第 10 章生产陷阱——Zod 的核心哲学就是一句话:

一份 schema 同时是值、类型、文档、契约
在边界校验,在内部相信 TS;在失败时给结构化错误,在成功时给类型安全的数据。
这就是现代 TypeScript 应用 防御式编程 + 类型驱动开发 的最佳实践。

写 schema 是早期投入,但每一行都在未来某天救你一次命。

推荐资源