在 TypeScript 中使用 Zod 进行运行时数据验证
1. 概述
TypeScript 的类型在编译后会被删除。因此它们无法保护你免受来自 API 的格式错误的 JSON、用户提交的表单或缺失值的环境变量的困扰。
本文介绍了如何使用 Zod 4 在运行时验证数据。你将声明一次 schema,然后从中推导出类型。你还将学习如何优雅地处理错误、编写测试并避免一些常见的陷阱。
2. 为什么 TypeScript 类型在运行时还不够
考虑这个熟悉的代码片段:
interface Customer {
id: number;
email: string;
}
const raw = '{"id": "42", "email": null}';
const customer = JSON.parse(raw) as Customer;
console.log(customer.id + 1);
console.log(customer.email.toLowerCase());
JSON.parse 返回 any。as Customer 只是对编译器的承诺,不会进行任何实际检查。运行结果:
421
TypeError: Cannot read properties of null (reading 'toLowerCase')
id 是字符串 "42",所以加法变成了字符串连接。email 是 null,所以程序崩溃。编译器没有任何警告。你需要一个在运行时实际运行的验证层。Zod 正好做到了这一点。
3. 安装
Zod 没有其他依赖项,可以在 Node.js 和浏览器中运行:
npm install zod
为了让 Zod 能准确推导类型,你需要在 tsconfig.json 中启用 strict 模式:
{
"compilerOptions": {
"strict": true
}
}
注意: 如果缺少
"strict": true,可选字段可能会被错误推导。你会失去大部分类型优势。
4. 定义 schema 并推导类型
Schema 描述了你期望的数据形状:
import { z } from "zod";
const CustomerSchema = z.object({
id: z.number().int().positive(),
email: z.email(),
fullName: z.string().min(2).max(100),
phone: z.string().optional(),
tier: z.enum(["FREE", "PRO", "ENTERPRISE"]).default("FREE"),
});
type Customer = z.infer<typeof CustomerSchema>;
各部分的作用如下:
z.number().int().positive()要求一个正整数。z.email()检查字符串是否是有效的电子邮件格式。在 Zod 4 中,email、URL、UUID 等字符串格式在顶层声明。.optional()允许字段缺失或具有undefined值。.default("FREE")当输入没有tier字段时自动填充值。z.infer从 schema 生成 TypeScript 类型。
当你 hover 到 Customer 时,你会看到等效的类型:
type Customer = {
id: number;
email: string;
fullName: string;
phone?: string | undefined;
tier: "FREE" | "PRO" | "ENTERPRISE";
}
强大之处在于:schema 是唯一的真实来源。当你向 schema 添加字段时,类型会自动更新。你不必维护一个平行的 interface。
5. parse 和 safeParse
Zod 有两种主要的验证方式。
5.1. parse:出错时抛出异常
const customer = CustomerSchema.parse({
id: 42,
email: "an.nguyen@example.com",
fullName: "Nguyễn Văn An",
});
console.log(customer);
{ id: 42, email: 'an.nguyen@example.com', fullName: 'Nguyễn Văn An', tier: 'FREE' }
返回的值已被验证并具有 Customer 类型。tier 字段被填充了默认值。如果数据不正确,parse 会抛出 ZodError。
5.2. safeParse:返回结果,不抛出错误
const result = CustomerSchema.safeParse({ id: "42", email: "not-an-email" });
if (!result.success) {
console.log(result.error.issues.map((i) => `${i.path.join(".")}: ${i.code}`));
} else {
console.log(result.data.email);
}
[ 'id: invalid_type', 'email: invalid_format', 'fullName: invalid_type' ]
result 是一个判别联合。在你检查 result.success 之后,TypeScript 确切知道哪个分支有 data,哪个有 error。你也会看到 Zod 不会在第一个错误处停止,而是收集所有错误。这在显示表单错误时非常方便。
选择规则很简单:
- 在系统边界处使用
safeParse,例如请求体或表单,其中错误是正常的。 - 当数据错误意味着 bug 或配置损坏,且应用程序应立即停止时,使用
parse。
6. 自定义错误消息和错误格式
Zod 的默认错误消息是英文且相当技术性。你可以传递 error 参数来替换:
const SignUpSchema = z.object({
email: z.email({ error: "Email 无效" }),
password: z.string().min(8, { error: "密码至少 8 个字符" }),
});
const result = SignUpSchema.safeParse({ email: "abc", password: "123" });
if (!result.success) {
console.log(z.flattenError(result.error));
}
{
formErrors: [],
fieldErrors: {
email: [ 'Email 无效' ],
password: [ '密码至少 8 个字符' ]
}
}
z.flattenError 按字段名称分组错误。这个格式适合返回给前端。当需要为人类读者记录时,使用 z.prettifyError(result.error) 获得多行易读的字符串。
7. Coerce、transform 和 refine
实际数据很少能完美匹配 schema。Zod 有三个工具来处理这种情况。
7.1. z.coerce:强制类型转换输入
查询字符串和环境变量总是字符串。z.coerce 在验证前转换它们:
const PaginationSchema = z.object({
page: z.coerce.number().int().min(1).default(1),
size: z.coerce.number().int().max(100).default(20),
});
console.log(PaginationSchema.parse({ page: "3", size: "50" }));
{ page: 3, size: 50 }
注意:
z.coerce.number()调用Number(input)。因此空字符串""变成0并通过类型检查。始终添加约束如.min(1)来防止这种情况。
7.2. transform:验证后转换
const OrderCodeSchema = z
.string()
.trim()
.regex(/^ord-\d{6}$/i, { error: "Mã đơn không đúng định dạng" })
.transform((code) => code.toUpperCase());
console.log(OrderCodeSchema.parse(" ord-000123 "));
ORD-000123
有了 transform 之后,输入和输出类型可能会不同。你可以使用 z.input<typeof S> 和 z.output<typeof S> 来分别获取每种类型。z.infer 等同于 z.output。
7.3. refine:自定义业务规则
有些约束涉及多个字段。你在对象级别放置 refine 来处理:
const BookingSchema = z
.object({
checkIn: z.coerce.date(),
checkOut: z.coerce.date(),
})
.refine((b) => b.checkOut > b.checkIn, {
error: "Ngày trả phòng phải sau ngày nhận phòng",
path: ["checkOut"],
});
const r = BookingSchema.safeParse({ checkIn: "2026-11-10", checkOut: "2026-11-08" });
console.log(r.success, r.error?.issues[0].path, r.error?.issues[0].message);
false [ 'checkOut' ] Ngày trả phòng phải sau ngày nhận phòng
path 将错误附加到 checkOut 字段而不是整个对象。这样前端就能在正确的位置显示错误。
8. 实际例子
8.1. 验证创建订单请求
下面的例子是一个处理请求的函数,独立于框架。你可以将其用于 Express、Fastify 或 Next.js:
import { z } from "zod";
const OrderItemSchema = z.object({
productId: z.uuid(),
quantity: z.number().int().min(1).max(99),
});
export const CreateOrderSchema = z.object({
customerId: z.number().int().positive(),
items: z.array(OrderItemSchema).nonempty({ error: "Đơn hàng phải có ít nhất 1 sản phẩm" }),
note: z.string().max(500).optional(),
});
export type CreateOrderRequest = z.infer<typeof CreateOrderSchema>;
export function handleCreateOrder(body: unknown) {
const result = CreateOrderSchema.safeParse(body);
if (!result.success) {
return { status: 400, body: z.flattenError(result.error).fieldErrors };
}
const order: CreateOrderRequest = result.data;
return { status: 201, body: { itemCount: order.items.length } };
}
注意 body 参数的类型是 unknown,不是 any。这强制 TypeScript 让你在使用之前进行验证。在 safeParse 之后,result.data 具有完整的类型。默认情况下,z.object 也会移除陌生字段,所以客户端无法将 isAdmin: true 这样的字段“塞入”你的对象中。
8.2. 在启动时验证环境变量
当配置错误时,应用程序应立即停止,不要等到处理第一个请求时才发现:
import { z } from "zod";
const EnvSchema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]),
PORT: z.coerce.number().int().default(3000),
DATABASE_URL: z.url(),
});
export const env = EnvSchema.parse(process.env);
假设缺少 DATABASE_URL。应用程序会在导入模块时立即抛出 ZodError,并明确指出 DATABASE_URL 缺失。在其他地方,env.PORT 的类型是 number,而不是 string | undefined。
9. 使用 Vitest 编写测试
Schema 包含业务规则,所以也需要测试。首先安装 Vitest:
npm install -D vitest
然后为 CreateOrderSchema 编写测试:
import { describe, it, expect } from "vitest";
import { z } from "zod";
import { CreateOrderSchema } from "./order";
describe("CreateOrderSchema", () => {
const validItem = { productId: "3f1c2a9e-8b4d-4c1a-9f2e-1a2b3c4d5e6f", quantity: 2 };
it("接受有效的订单并丢弃未知字段", () => {
const result = CreateOrderSchema.safeParse({
customerId: 7,
items: [validItem],
isAdmin: true,
});
expect(result.success).toBe(true);
expect(result.data).toEqual({ customerId: 7, items: [validItem] });
});
it("拒绝没有商品的订单", () => {
const result = CreateOrderSchema.safeParse({ customerId: 7, items: [] });
expect(result.success).toBe(false);
expect(z.flattenError(result.error!).fieldErrors.items).toContain(
"Đơn hàng phải có ít nhất 1 sản phẩm",
);
});
it("拒绝超出数量限制", () => {
const result = CreateOrderSchema.safeParse({
customerId: 7,
items: [{ ...validItem, quantity: 100 }],
});
expect(result.error?.issues[0].path).toEqual(["items", 0, "quantity"]);
});
});
运行 npx vitest run,你会看到所有三个测试都通过。第三个测试显示错误的 path 一直到数组中的元素。这正是前端需要的信息,以便在正确的输入字段处显示红色。
10. 常见错误和何时不应使用
在已有 schema 后使用 as 进行强制转换。 写 body as CreateOrderRequest 会破坏整个保护机制。始终从 result.data 获取数据。
使用异步 refine 与 parse。 例如,你检查电子邮件是否已存在于数据库中。当有异步 refine 时,你必须调用 parseAsync 或 safeParseAsync。否则,Zod 会抛出错误。
期望拒绝未知字段。 z.object 默认会静默删除未知字段。如果你想在有多余字段时报错,例如捕获配置文件中的拼写错误,请使用 z.strictObject(...)。
在应用程序内重复验证。 你应该只在系统边界处验证:请求、来自第三方 API 的响应、消息队列、配置文件。已验证的数据可以信任其类型。在每个函数中重新解析只会浪费 CPU。
注意: 不要在热循环中使用 Zod,那里需要每秒处理数十万个对象。验证确实有真实的成本。在这些地方,在输入处进行一次检查,或选择一个将 schema 编译为代码的库。
在函数内声明 schema。 如果你在每次调用函数时都创建 schema,应用程序会浪费额外的初始化成本。在模块级别声明 schema 并重新使用。
11. 结论
Zod 弥补了编译时类型和运行时真实数据之间的差距。你声明一次 schema,使用 z.infer 推导类型,然后在系统边界处使用 safeParse 来获得清洁数据或结构化错误列表。
使用 coerce、transform 和 refine,你可以处理大多数实际情况:查询字符串、数据规范化、多字段业务规则。尽早验证,在边界处验证,让 TypeScript 处理其余部分。