在 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 处理其余部分。

typescriptzodvalidationnodejs