运行时验证:zod + TS 类型自动推导
第18章:运行时验证:zod + TS 类型自动推导
理解运行时验证是掌握 TypeScript 类型系统的关键一步。
本章核心问题:如何在实际项目中正确使用zod + TS 类型自动推导?关键的设计决策和陷阱是什么?
读完本章你将理解:
- 根本性的缺口
- zod 基础:schema 既是验证器也是类型
- 常用 zod 类型
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 存在
何时用哪个:
parse():你期望数据一定合法(比如内部数据),失败应该是 bug,抛异常合理safeParse():处理外部输入(API 请求体、表单数据),需要给用户友好的错误信息
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.infer 的 input/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 个阶段平稳迁移——每个阶段都有具体的文件操作策略和可以衡量的里程碑。