TypeScript 与 Zod 结合:构建端到端类型安全的表单验证方案

表单验证是前端开发中无法回避的一环。传统做法通常是在提交时手动检查字段、编写大量 if-else,或者依赖某个 UI 库自带的校验规则。这些方案能跑通,但有一个共同的痛点:类型定义和运行时校验是两套东西。你在 TypeScript 里声明了一个 User 接口,又在表单验证逻辑里重新写一遍字段规则,两边一旦不同步,bug 就来了。

Zod 的出现让这个问题有了优雅的解法。它是一个以 TypeScript 为中心的 schema 声明与验证库,核心思路是:schema 即类型,类型即 schema。你只需要定义一次,TypeScript 类型和运行时验证逻辑就同时有了。

为什么是 Zod

Zod 的设计有几个关键优势:

  • 零依赖,体积小,tree-shaking 友好
  • 类型推断:通过 z.infer<typeof schema> 直接从 schema 推导出 TypeScript 类型,不需要手写 interface
  • 链式 APIz.string().min(3).email() 这样的写法直观且可组合
  • 错误信息结构化:验证失败时返回的 ZodError 包含每个字段的详细错误路径和消息,方便映射到表单 UI

与 Yup、Joi 等老牌方案相比,Zod 最大的不同是它对 TypeScript 类型系统的深度利用。Yup 虽然也支持类型推断,但在复杂联合类型、 discriminated union 等场景下,Zod 的表现更自然。

从 Schema 到类型:一次定义,两端使用

假设我们要做一个用户注册表单,包含用户名、邮箱、密码和确认密码。用 Zod 定义 schema:

import { z } from "zod";

const registerSchema = z
  .object({
    username: z
      .string()
      .min(3, "用户名至少 3 个字符")
      .max(20, "用户名最多 20 个字符"),
    email: z.string().email("请输入有效的邮箱地址"),
    password: z
      .string()
      .min(8, "密码至少 8 位")
      .regex(/[A-Z]/, "密码需包含大写字母")
      .regex(/[0-9]/, "密码需包含数字"),
    confirmPassword: z.string(),
  })
  .refine((data) => data.password === data.confirmPassword, {
    message: "两次输入的密码不一致",
    path: ["confirmPassword"],
  });

type RegisterForm = z.infer<typeof registerSchema>;

注意最后一行。RegisterForm 这个类型完全从 schema 推导而来,包含 usernameemailpasswordconfirmPassword 四个 string 字段。如果你后续修改了 schema,比如给 username 加上 .optional()RegisterForm 会自动变成可选,所有使用该类型的地方都会得到正确的类型提示。

这就是“端到端类型安全”的起点:类型不再是你手动维护的契约,而是 schema 的副产品

在表单中集成 Zod

以 React Hook Form 为例,它和 Zod 的集成非常顺滑。通过 @hookform/resolvers/zod,你可以直接把 Zod schema 作为 resolver 传入:

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";

function RegisterForm() {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<RegisterForm>({
    resolver: zodResolver(registerSchema),
  });

  const onSubmit = (data: RegisterForm) => {
    // data 已经通过 Zod 验证,类型完全安全
    console.log(data.email); // string
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("username")} />
      {errors.username && <span>{errors.username.message}</span>}

      <input {...register("email")} />
      {errors.email && <span>{errors.email.message}</span>}

      <input type="password" {...register("password")} />
      {errors.password && <span>{errors.password.message}</span>}

      <input type="password" {...register("confirmPassword")} />
      {errors.confirmPassword && <span>{errors.confirmPassword.message}</span>}

      <button type="submit">注册</button>
    </form>
  );
}

这里有几个关键点:

  1. useForm<RegisterForm> 的泛型来自 z.infer,表单字段名和类型都有自动补全
  2. zodResolver 在提交时执行验证,错误信息自动填充到 errors 对象
  3. onSubmit 收到的 data 已经过验证,不需要再做任何运行时检查

如果你不用 React Hook Form,也可以手动调用 registerSchema.safeParse(formData),根据返回的 success 字段决定后续逻辑。safeParse 不会抛异常,而是返回一个 discriminated union:

const result = registerSchema.safeParse(formData);

if (!result.success) {
  // result.error.issues 包含所有验证错误
  const fieldErrors = result.error.flatten().fieldErrors;
  // fieldErrors.email 是一个 string[] | undefined
} else {
  // result.data 类型为 RegisterForm
}

flatten() 方法把错误按字段分组,非常适合直接映射到表单 UI。

服务端复用同一套 Schema

端到端类型安全的关键在于“端到端”。Zod schema 不依赖任何浏览器 API,可以无缝地在 Node.js 服务端复用。假设你用 Express 或 Next.js API Route 处理注册请求:

import { registerSchema } from "./schemas";

app.post("/api/register", async (req, res) => {
  const result = registerSchema.safeParse(req.body);

  if (!result.success) {
    return res.status(400).json({
      errors: result.error.flatten().fieldErrors,
    });
  }

  const { username, email, password } = result.data;
  // 这里 password 已经满足强度要求,email 格式已验证
  // 直接进入业务逻辑
});

前端和后端共享同一个 schema 文件,意味着:

  • 前端提示的验证规则和后端强制执行的规则完全一致
  • 修改验证逻辑时只需改一处
  • API 的请求体类型和响应类型都可以从 schema 推导,前后端接口契约不会漂移

如果你使用 tRPC 或类似的端到端类型安全框架,Zod 更是天然的一等公民。tRPC 的 input 验证直接接受 Zod schema,客户端调用时自动获得类型提示和运行时校验。

进阶模式: discriminated union 与条件验证

实际业务中,表单往往不是扁平的。比如一个支付表单,根据支付方式不同,需要填写的字段也不同:

const paymentSchema = z.discriminatedUnion("method", [
  z.object({
    method: z.literal("card"),
    cardNumber: z.string().length(16),
    expiry: z.string(),
    cvv: z.string().length(3),
  }),
  z.object({
    method: z.literal("bank"),
    bankAccount: z.string(),
    routingNumber: z.string(),
  }),
]);

z.discriminatedUnion 会根据 method 字段的值自动选择对应的分支进行验证。推导出的类型也是一个联合类型,在代码中通过 if (data.method === "card") 就能收窄到正确的分支,TypeScript 会自动提示 cardNumber 等字段。

这种模式在传统表单验证库中很难优雅实现,而 Zod 借助 TypeScript 的 discriminated union 类型,做到了类型和验证逻辑的完全同步。

总结

Zod 解决的核心问题是 schema 和类型的单一来源。在它之前,你要么手写两套(类型 + 验证),要么接受验证库类型推断能力的不足。Zod 让“定义一次,两端使用”成为现实:

  • 前端表单验证用 zodResolver,错误信息结构化
  • 后端 API 验证用 safeParse,请求体类型自动推导
  • 复杂场景用 discriminatedUnionrefinetransform 等组合子,类型收窄依然准确

如果你的项目已经用 TypeScript,但表单验证还在手写 if-else 或者类型和校验逻辑各写各的,Zod 值得一试。它不会让你的代码变复杂,反而会减少你需要维护的代码量——尤其是那些你写了两遍、却总有一遍忘了更新的字段规则。

未经允许不得转载:任鹏个人博客 » TypeScript 与 Zod 结合:构建端到端类型安全的表单验证方案

赞 (0) 打赏

评论 0

取消
  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

微信扫一扫打赏