首页 / 文章 / 在每个 Next.js 数据边界处用 Zod 解析替代 as-Casts

在每个 Next.js 数据边界处用 Zod 解析替代 as-Casts

为何 TypeScript 类型转换无法防止 API 变更,以及如何利用 Zod 模式在 Next.js 中验证获取的数据、表单、路由处理程序和服务器动作。

1102 词

在开发阶段,使用类型定义的组件看似安全,但一旦生产环境发送了重命名的字段、字符串被替换为null,或是错误数据而非用户数据,这些问题就会显现。TypeScript无法捕捉到这些情况:其类型在编译时就已经消失,而网络数据仅在运行时存在,因此as User只是一种断言而非真正的类型检查。本指南将展示如何通过一个Zod模式同时实现数据的验证与TypeScript类型的生成,以及如何在React和Next.js应用的各个环节应用它:fetch请求结果、表单、路由处理函数和服务器动作。

不可信的JSON才是真正的问题

任何非由代码自行生成的负载,无论是fetch响应、请求体、服务器操作输入还是webhook,都值得怀疑。若跳过运行时检查,就会出现盲目类型转换、与接口不符的验证器,以及客户端和服务器类型不一致的问题。Zod将这些问题统一处理:只需修改架构,推断出的类型便会随之改变。

定义架构,推导类型

从导入部分开始:

import { z } from "zod";

下面的架构描述了一个用户资料,z.infer会将其转换为TypeScript类型,而loadProfile则会在返回响应之前先通过parse对其进行处理。

export const UserProfileSchema = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
  displayName: z.string().min(1).optional(),
});export type UserProfile = z.infer<typeof UserProfileSchema>;async function loadProfile(id: string): Promise<UserProfile> {
  const res = await fetch(`/api/users/${id}`);
  const data = await res.json();
  return UserProfileSchema.parse(data);
}

对比return data as UserProfile:一旦API违反约定,解析就会立即抛出错误;而类型转换则会让不良数据继续传递,直到在远离问题根源的地方引发故障。

在 UI 代码中,使用 safeParse 通常更好:它会返回一个结果对象而非抛出异常,这样你可以自行处理错误情况:

const result = UserProfileSchema.safeParse(data);
if (!result.success) {
  console.error(result.error.flatten());
  return null;
}

向提交处理函数传递有效数据的表单

通过 zodResolver,React Hook Form 会在值传入 handleSubmit 之前对其进行验证。该文件属于客户端组件:

"use client";

字段错误信息也来自模式定义,这样就能让 UI 反馈与数据类型保持一致:

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";const SignupSchema = z.object({
  email: z.string().email("Enter a valid email"),
  password: z.string().min(8, "At least 8 characters"),
});type SignupValues = z.infer<typeof SignupSchema>;export function SignupForm() {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<SignupValues>({
    resolver: zodResolver(SignupSchema),
  });  return (
    <form onSubmit={handleSubmit((values) => console.log(values))}>
      <input type="email" {...register("email")} />
      {errors.email && <p>{errors.email.message}</p>}
      <input type="password" {...register("password")} />
      {errors.password && <p>{errors.password.message}</p>}
      <button type="submit">Create account</button>
    </form>
  );
}

在真实应用中,应将 SignupSchema 移至共享模块中,而非在组件文件内定义,这样服务器就可以导入相同的规则。

在 Next.js 入口点进行验证

路由处理函数

路由处理函数需要 NextResponse、Zod 以及共享的配置模式:

import { NextResponse } from "next/server";
import { z } from "zod";
import { UserProfileSchema } from "@/lib/schemas/user";

处理程序使用safeParse验证请求体,失败时返回状态码400并附带汇总后的错误信息。同时它还会根据UserProfileSchema解析自身的响应,从而确保输出符合客户端的约定。硬编码的id用于表示数据库中的插入操作。

const CreateUserSchema = z.object({
  email: z.string().email(),
  displayName: z.string().min(1).max(80).optional(),
});export async function POST(request: Request) {
  const parsed = CreateUserSchema.safeParse(await request.json());
  if (!parsed.success) {
    return NextResponse.json(
      { error: "Invalid body", details: parsed.error.flatten() },
      { status: 400 }
    );
  }  const created = {
    id: "11111111-1111-1111-1111-111111111111",
    email: parsed.data.email,
    displayName: parsed.data.displayName,
  };  return NextResponse.json(UserProfileSchema.parse(created), { status: 201 });
}

服务器动作

一个服务器动作模块以如下指令开始:

"use server";

该动作会根据FormData构建一个对象,并使用与表单相同的SignupSchema对其进行验证。通过以字面量类型(as const)返回ok,调用者可以更清晰地获取结果:

import { SignupSchema } from "@/lib/schemas/auth";export async function signupAction(formData: FormData) {
  const parsed = SignupSchema.safeParse({
    email: formData.get("email"),
    password: formData.get("password"),
  });  if (!parsed.success) {
    return { ok: false as const, errors: parsed.error.flatten().fieldErrors };
  }  return { ok: true as const };
}

客户端与服务器共享同一个模式模块,可解决“在表单中有效但被服务器拒绝”的问题。对于 Next.js 之外的类似场景,可参阅在 React 前端与 Node 后端之间共享同一个 Zod 模式。

让模式易于维护的习惯

  • 将所有模式集中存放,例如放在 lib/schemas/* 目录下。
  • 使用 .extend、.pick 和 .omit 方法生成变体,而非重复字段。
  • 将 .transform 用于字符串修剪或日期解析等简单的清理工作,切勿用于隐藏的业务规则。
  • 当数据结构的形态取决于状态字段时,使用 z.discriminatedUnion。
  • 在启动时仅解析一次环境变量。

实际应用中的组成结构如下。基础模式包含共享字段:

const BaseUser = z.object({
  email: z.string().email(),
  displayName: z.string().optional(),
});

在此基础上,更新模式通过.partial()将所有字段设为可选,而响应DTO则通过.extend()添加服务器端的字段:

export const UpdateUserSchema = BaseUser.partial();
export const UserDtoSchema = BaseUser.extend({
  id: z.string().uuid(),
  createdAt: z.string().datetime(),
});

需要注意一点:较新版本的Zod引入了如z.email()和z.uuid()这样的顶层格式,并改变了错误处理的机制。此处展示的链式结构在您的版本中可能已过时,因此请查阅最新的Zod文档。

关键要点

  • 类型用于描述设计意图;只有运行时解析才能在网络层强制执行该规则。
  • 从Zod模式推断TypeScript类型,以避免两者出现偏差。
  • 若需要处理失败情况,请使用safeParse;若希望失败时直接抛出异常,则使用parse。
  • 为表单、处理程序和动作复用同一套架构。
  • 选择最危险的 as 类型转换,并首先为其定义架构。