在每个 Next.js 数据边界处用 Zod 解析替代 as-Casts
为何 TypeScript 类型转换无法防止 API 变更,以及如何利用 Zod 模式在 Next.js 中验证获取的数据、表单、路由处理程序和服务器动作。
在开发阶段,使用类型定义的组件看似安全,但一旦生产环境发送了重命名的字段、字符串被替换为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 类型转换,并首先为其定义架构。