第 18 章

运行时验证:zod + TS 类型自动推导

第18章:运行时验证:zod + TS 类型自动推导

理解运行时验证是掌握 TypeScript 类型系统的关键一步。

本章核心问题:如何在实际项目中正确使用zod + TS 类型自动推导?关键的设计决策和陷阱是什么?

读完本章你将理解


Level 1 · 你需要知道的(1-3年经验)

根本性的缺口

TypeScript 的类型系统只在编译时存在。当你的程序运行起来,接收 API 响应、读取用户输入、解析环境变量时,TypeScript 消失了。这意味着:

// 你以为 API 返回这个类型
interface User {
  id: number;
  name: string;
  email: string;
}

// 实际运行时 fetch 返回的是 any
const response = await fetch("/api/user/1");
const user = await response.json() as User; // as 只是告诉编译器"相信我"

// API 后端返回了 { id: "abc", name: null, email: "..." }
// TypeScript 编译通过,运行时崩溃
console.log(user.id.toFixed(2)); // TypeError: user.id.toFixed is not a function

as User 是谎言。你只是把类型强制断言成了你期望的形状,但实际数据可能完全不同。

zod 解决这个问题的方式:定义一个 schema,它在运行时真正检查数据,同时自动推导出 TypeScript 类型。


zod 基础:schema 既是验证器也是类型

npm install zod
import { z } from "zod";

// 定义 schema
const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
});

// 从 schema 自动推导 TypeScript 类型
type User = z.infer<typeof UserSchema>;
// 等价于:
// type User = {
//   id: number;
//   name: string;
//   email: string;
// }

// 运行时验证
const data = JSON.parse(responseText);
const user = UserSchema.parse(data); // 验证通过返回 User,失败抛出异常
// user 现在是真正的 User 类型,不是断言

你只写一次 schema,类型是推导出来的,不是手动同步的。这消除了"类型定义和运行时检查不一致"的整类 bug。


常用 zod 类型

基本类型

import { z } from "zod";

// 基本类型
z.string()
z.number()
z.boolean()
z.date()
z.bigint()
z.symbol()
z.undefined()
z.null()
z.any()
z.unknown()
z.never()
z.void()

// 字面量
z.literal("admin")         // 只接受字符串 "admin"
z.literal(42)
z.literal(true)

字符串精化

const EmailSchema = z.string()
  .email("无效的邮箱格式")
  .toLowerCase()           // 转换:转为小写
  .trim();                 // 转换:去除首尾空格

const PasswordSchema = z.string()
  .min(8, "密码至少8位")
  .max(100, "密码不超过100位")
  .regex(/[A-Z]/, "必须包含大写字母")
  .regex(/[0-9]/, "必须包含数字");

const SlugSchema = z.string()
  .regex(/^[a-z0-9-]+$/, "只能包含小写字母、数字和连字符");

const UrlSchema = z.string().url("无效的URL");

// UUID
const IdSchema = z.string().uuid("无效的ID格式");

数字精化

const AgeSchema = z.number()
  .int("年龄必须是整数")
  .min(0, "年龄不能为负")
  .max(150, "年龄超出范围");

const PriceSchema = z.number()
  .positive("价格必须为正数")
  .finite("价格不能是无穷大");

const PercentSchema = z.number().min(0).max(100);

枚举

// zod 枚举(推荐用于固定字符串集合)
const StatusSchema = z.enum(["pending", "active", "inactive", "deleted"]);
type Status = z.infer<typeof StatusSchema>; // "pending" | "active" | "inactive" | "deleted"

// 与 TypeScript enum 配合
enum Role {
  Admin = "admin",
  User = "user",
  Guest = "guest",
}
const RoleSchema = z.nativeEnum(Role);
type RoleType = z.infer<typeof RoleSchema>; // Role

数组和对象

// 数组
const TagsSchema = z.array(z.string()).min(1, "至少一个标签").max(10, "最多10个标签");

// 元组(固定长度和类型)
const CoordinateSchema = z.tuple([z.number(), z.number()]); // [lat, lng]

// 对象
const AddressSchema = z.object({
  street: z.string(),
  city: z.string(),
  country: z.string().length(2, "国家代码必须是2位"),
  zipCode: z.string().optional(),
});

// 嵌套对象
const UserSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(1),
  email: z.string().email(),
  address: AddressSchema.optional(),
  tags: z.array(z.string()).default([]),
  createdAt: z.date(),
});

可选、可空、默认值

z.string().optional()          // string | undefined
z.string().nullable()          // string | null
z.string().nullish()           // string | null | undefined

z.string().default("anonymous")  // 如果值是 undefined,使用默认值
z.number().default(() => Date.now())  // 动态默认值

联合和交叉

// 联合类型
const StringOrNumber = z.union([z.string(), z.number()]);
// 简写:
const StringOrNumber2 = z.string().or(z.number());

// 有区分符的联合(更高效的解析)
const ShapeSchema = z.discriminatedUnion("type", [
  z.object({ type: z.literal("circle"), radius: z.number() }),
  z.object({ type: z.literal("rectangle"), width: z.number(), height: z.number() }),
]);

type Shape = z.infer<typeof ShapeSchema>;
// { type: "circle"; radius: number } | { type: "rectangle"; width: number; height: number }

// 交叉类型
const WithTimestampsSchema = z.object({
  createdAt: z.date(),
  updatedAt: z.date(),
});

const ArticleSchema = z.object({
  title: z.string(),
  content: z.string(),
}).merge(WithTimestampsSchema); // 等价于 z.intersection(...)

parse() vs safeParse():抛异常 vs 返回结果

const UserSchema = z.object({
  name: z.string(),
  age: z.number().int().positive(),
});

// parse():验证失败时抛出 ZodError
try {
  const user = UserSchema.parse({ name: "Alice", age: -5 });
} catch (err) {
  if (err instanceof z.ZodError) {
    console.log(err.issues);
    // [{ code: 'too_small', minimum: 0, type: 'number', message: 'Number must be greater than 0', path: ['age'] }]
  }
}

// safeParse():返回 { success: boolean, data?: T, error?: ZodError }
const result = UserSchema.safeParse({ name: "Alice", age: -5 });

if (result.success) {
  console.log(result.data); // User 类型
} else {
  console.log(result.error.issues); // ZodIssue[]
}

// TypeScript 能正确区分两种情况:
// result.success 为 true 时,result.data 存在
// result.success 为 false 时,result.error 存在

何时用哪个:


Level 2 · 它是怎么运行的(3-5年经验)

实战 1:验证 API 响应

不再用 as ApiResponse,改用 zod 真正验证数据。

import { z } from "zod";

// 定义期望的响应结构
const ProductSchema = z.object({
  id: z.number(),
  name: z.string(),
  price: z.number().positive(),
  category: z.enum(["electronics", "clothing", "food"]),
  inStock: z.boolean(),
  images: z.array(z.string().url()),
  metadata: z.record(z.string(), z.unknown()).optional(),
});

type Product = z.infer<typeof ProductSchema>;

const ProductListSchema = z.object({
  items: z.array(ProductSchema),
  total: z.number().int().nonnegative(),
  page: z.number().int().positive(),
  pageSize: z.number().int().positive(),
});

// 封装带类型验证的 fetch
async function fetchProducts(page: number): Promise<z.infer<typeof ProductListSchema>> {
  const response = await fetch(`/api/products?page=${page}`);
  
  if (!response.ok) {
    throw new Error(`HTTP ${response.status}: ${response.statusText}`);
  }
  
  const data = await response.json();
  
  // 这里真正验证数据,不是假装
  const result = ProductListSchema.safeParse(data);
  
  if (!result.success) {
    // 把 zod 错误格式化后记录日志
    console.error("API response validation failed:", result.error.format());
    throw new Error("Invalid API response shape");
  }
  
  return result.data; // 真正的 ProductList 类型,不是断言
}

实战 2:启动时验证环境变量

在应用启动的第一行验证所有必需的环境变量,比在运行时各处随机崩溃好得多。

import { z } from "zod";

const EnvSchema = z.object({
  // 数据库
  DATABASE_URL: z.string().url("DATABASE_URL 必须是有效的URL"),
  DATABASE_POOL_SIZE: z.coerce.number().int().min(1).max(100).default(10),
  
  // 认证
  JWT_SECRET: z.string().min(32, "JWT_SECRET 至少32个字符"),
  JWT_EXPIRES_IN: z.string().default("7d"),
  
  // 应用配置
  PORT: z.coerce.number().int().min(1).max(65535).default(3000),
  NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
  LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
  
  // 外部服务(可选)
  REDIS_URL: z.string().url().optional(),
  SMTP_HOST: z.string().optional(),
  SMTP_PORT: z.coerce.number().int().optional(),
});

// 类型自动推导
type Env = z.infer<typeof EnvSchema>;

// 在模块顶层执行,启动时立即验证
const parseResult = EnvSchema.safeParse(process.env);

if (!parseResult.success) {
  console.error("环境变量配置错误:");
  
  // 格式化错误信息
  for (const issue of parseResult.error.issues) {
    const path = issue.path.join(".");
    console.error(`  ${path}: ${issue.message}`);
  }
  
  process.exit(1); // 快速失败,不要带着错误配置运行
}

// 导出验证后的 env,整个应用使用这个
export const env: Env = parseResult.data;

// 使用:
// import { env } from "./env";
// const db = createConnection(env.DATABASE_URL);

z.coerce.number() 是关键——环境变量都是字符串,coerce 会先尝试类型转换再验证,把 "3000" 转成 3000


实战 3:表单验证与错误信息

import { z } from "zod";

// 注册表单 schema
const RegisterSchema = z.object({
  username: z.string()
    .min(3, "用户名至少3个字符")
    .max(20, "用户名最多20个字符")
    .regex(/^[a-zA-Z0-9_]+$/, "用户名只能包含字母、数字和下划线"),
  
  email: z.string()
    .email("请输入有效的邮箱地址"),
  
  password: z.string()
    .min(8, "密码至少8个字符")
    .regex(/[A-Z]/, "密码必须包含至少一个大写字母")
    .regex(/[0-9]/, "密码必须包含至少一个数字"),
  
  confirmPassword: z.string(),
  
  agreeToTerms: z.boolean().refine(val => val === true, {
    message: "必须同意服务条款",
  }),
}).refine(data => data.password === data.confirmPassword, {
  message: "两次输入的密码不一致",
  path: ["confirmPassword"], // 错误挂在 confirmPassword 字段上
});

type RegisterForm = z.infer<typeof RegisterSchema>;

// 处理表单提交
function handleRegister(formData: unknown) {
  const result = RegisterSchema.safeParse(formData);
  
  if (!result.success) {
    // 把错误转为字段映射(适合在表单 UI 里展示)
    const fieldErrors = result.error.flatten().fieldErrors;
    // {
    //   username: ["用户名至少3个字符"],
    //   email: ["请输入有效的邮箱地址"],
    //   confirmPassword: ["两次输入的密码不一致"]
    // }
    
    return { success: false, errors: fieldErrors };
  }
  
  // result.data 是 RegisterForm 类型,confirmPassword 和 agreeToTerms 都在
  const { username, email, password } = result.data;
  return { success: true, data: { username, email, password } };
}

数据转换:.transform().preprocess()

zod schema 不只是验证,还可以在验证时转换数据

// .transform():验证通过后转换
const DateFromStringSchema = z.string()
  .datetime()
  .transform(str => new Date(str));

type DateFromString = z.infer<typeof DateFromStringSchema>; // Date(不是 string)

const date = DateFromStringSchema.parse("2024-01-15T10:30:00Z"); // Date 对象

// 更复杂的转换
const UserInputSchema = z.object({
  name: z.string().trim(),
  email: z.string().email().toLowerCase(),
  birthYear: z.number().int(),
}).transform(data => ({
  ...data,
  age: new Date().getFullYear() - data.birthYear, // 派生字段
  displayName: data.name.split(" ")[0], // 派生字段
}));

type UserDisplay = z.infer<typeof UserInputSchema>;
// { name: string; email: string; birthYear: number; age: number; displayName: string }

// .preprocess():验证前预处理(类型转换)
const NumberFromAnythingSchema = z.preprocess(
  (val) => {
    if (typeof val === "string") return parseFloat(val);
    if (typeof val === "boolean") return val ? 1 : 0;
    return val;
  },
  z.number()
);

NumberFromAnythingSchema.parse("3.14"); // 3.14
NumberFromAnythingSchema.parse(true);   // 1
NumberFromAnythingSchema.parse(42);     // 42

.preprocess()z.coerce 的区别:coerce 用内置的 JavaScript 类型转换规则,preprocess 让你完全控制转换逻辑。


对比表:zod vs io-ts vs valibot vs arktype

特性 zod io-ts valibot arktype
类型推导 z.infer<typeof S> t.TypeOf<typeof S> InferOutput<typeof S> typeof S.infer
Bundle 大小 ~14KB gzip ~6KB ~10KB(模块化) ~12KB
错误信息 内置,可自定义 需要 fp-ts 内置 内置
学习曲线 高(依赖 fp-ts)
性能 中等 中等 较快 最快
生态系统 最成熟 成熟 增长中 新兴
异步验证 支持 有限 支持 支持
适用场景 通用 函数式编程项目 对包大小敏感 极致性能

在 2024-2025 年,zod 是绝大多数项目的默认选择,主要原因是生态系统最成熟(tRPC、React Hook Form、Prisma 等都有一流的 zod 集成)。


Level 3 · 规范怎么定义的(资深)

zod 的核心设计是"schema 即类型":一个 zod schema 对象同时是运行时验证器和编译时类型定义的来源。z.infer<typeof Schema> 利用 TypeScript 的 typeof 操作符和条件类型从 schema 对象反向推导出类型。safeParse() 返回的是一个判别联合({ success: true; data: T } | { success: false; error: ZodError }),这与第 14 章的 Result<T, E> 模式完全一致。zod 的 .transform() 使得输入类型和输出类型可以不同——这在编译时通过 z.inferinput/output 两个推导方向来体现。

Level 4 · 边界与陷阱(所有人)

反模式

反模式 1:在内部函数调用里到处用 zod

// 错误:zod 验证有开销,不要在热路径上用
function calculateTotal(items: CartItem[]): number {
  // 错误:items 已经是类型安全的,不需要再验证
  const validated = z.array(CartItemSchema).parse(items);
  return validated.reduce((sum, item) => sum + item.price * item.quantity, 0);
}

// 正确:只在边界处验证(API 输入、环境变量等),内部函数信任 TypeScript 类型
function calculateTotal(items: CartItem[]): number {
  return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}

// 在 API 处理器里,进来的时候验证一次
app.post("/cart/checkout", async (req, res) => {
  const result = CheckoutRequestSchema.safeParse(req.body);
  if (!result.success) return res.status(400).json({ errors: result.error.format() });
  
  // 之后传给内部函数的都是已验证的类型
  const total = calculateTotal(result.data.items);
});

反模式 2:validate 但忘了用 safeParse 的结果

// 错误:调用了 safeParse 但没检查 success
const result = UserSchema.safeParse(data);
console.log(result.data.name); // result.data 可能是 undefined

// 正确
const result = UserSchema.safeParse(data);
if (result.success) {
  console.log(result.data.name); // 安全
}

反模式 3:用 z.any() 逃避麻烦

// 错误:完全失去了用 zod 的意义
const ApiResponseSchema = z.object({
  status: z.string(),
  data: z.any(), // 相当于没有验证
});

// 正确:如果不确定数据结构,用 z.unknown() 并在使用前精化
const ApiResponseSchema = z.object({
  status: z.string(),
  data: z.unknown(),
});

// 使用时精化
function processApiData(schema: z.ZodType, rawData: unknown) {
  return schema.parse(rawData);
}

汇总表

场景 推荐方法 理由
API 响应验证 safeParse() + 错误处理 外部数据不可信
启动时环境变量 parse() + process.exit(1) 失败就不应该启动
表单验证 safeParse() + flatten() 需要字段级错误信息
内部函数参数 不用 zod TypeScript 类型已足够
数据转换 .transform() 验证和转换合一
字符串转数字 z.coerce.number() 环境变量、query 参数常见
跨字段验证 .refine() 密码确认、日期范围等

下一章预告

第 19 章讲如何把一个 10 万行的 JavaScript 代码库渐进迁移到 TypeScript:为什么大爆炸式重写必然失败,以及如何分 4 个阶段平稳迁移——每个阶段都有具体的文件操作策略和可以衡量的里程碑。

本章评分
4.7  / 5  (12 评分)

💬 留言讨论