Prisma + TypeScript + Zod 打造端到端类型安全
那天下午,产品经理又来了
那次是真的有点卧槽。
我在做一个用户注册系统。Prisma 管数据库,Next.js 跑前后端,Ant Design 画表单。功能跑得好好的,代码也干净。
产品经理走过来,拉了把椅子坐下。"用户名限制改成30个字符吧,原来是20个。密码最短改成8位。还有邮箱改成必填。"
我说行,五分钟的事。
然后我打开了三个文件。
第一个是 schema.prisma。找到 User model,把 @db.VarChar(20) 改成 @db.VarChar(30),把 password 的注释从 .min(6) 改成 .min(8),把 email 从 String? 改成 String。
第二个是后端的 Zod schema 文件。src/shared/schemas/user.schema.ts。.max(20) 改成 .max(30),.min(6) 改成 .min(8),把 email 的 .optional() 删掉。
第三个是前端表单组件。src/components/RegisterForm.tsx。找到 maxLength=\{20\} 改成 maxLength=\{30\},找到密码提示文字"最少6个字符"改成"最少8个字符"。
改完了。运行。测了一下。通了。
但我盯着屏幕,心里涌上来一股他妈的很荒谬的感觉。
凭什么?
"用户名最多几个字符"这件事,本质上是一条规则。一条。
但我在三个文件里说了三遍。而且每一遍都是手写的,没有任何东西保证这三遍说的是同一件事。
你这辈子大概率也遇到过这种事。你在周五下午重构了数据库,下周一早上发现前端表单还在用旧的限制。或者你改了后端的校验,前端的错误提示还停留在三个月前的版本。用户填了个合法的新格式,前端说"格式不正确"。
用户不知道你改了后端,他只知道你弹的那个红字很烦。
这不是你粗心。这是架构在逼你粗心。
三道门,三个守门人,同一个规矩
你写的任何一个 Web 应用,用户输入的数据要经过三道门。
第一道门,前端表单。门卫拿着你的规则手册,检查每个框里填的东西。这扇门存在的理由很简单——让用户早点知道填错了,别等到点提交按钮了才告诉我。
第二道门,后端 API。门卫比前端的凶。他不管你是不是"不小心填错了",格式不对就原路打回。这扇门是安全的底线,因为他假设前端可能被绕过了——比如有人用 curl 直接打你的接口。
第三道门,数据库。这是最后一道物理防线。你在建表的时候就规定了:这个字段最长存多少字节,允不允许为空。数据要落地就得遵守。
这三道门,三个门卫,守的都是同一套规矩。
问题是,这个规矩的"原件"在哪?
卧槽,这也是问题所在。在很多项目里,这个规矩根本没原件。每一扇门的门卫手持的都是自己手抄的一份。第一份抄在 Ant Design 的 Form.Item 里,第二份抄在 Zod schema 里,第三份抄在 schema.prisma 里。
三份手抄本,三个独立的副本,没有任何同步机制。
当产品经理让你改规矩的时候,你当然可能漏掉其中一份。不是你的问题,是这门生意从一开始就设计错了。
真正的类型安全,有两条腿
这里得先聊一个很多人搞混的概念。
"我装了 TypeScript,所以我的项目是类型安全的。"
卧槽,真不是。
TypeScript 做的是编译时检查。你在 VSCode 里敲代码的时候,如果类型对不上,IDE 会在那一行底下画红线。你写 user.age.push(1),它马上跳出来说:age 是 number,number 没有 push 方法,你他妈的别乱写。
这个检查发生在你打包上线之前。
一旦 npm run build 跑完,Browser 里跑的是 JavaScript。JavaScript 不认识 User 类型,不关心 age 是不是 number。类型信息在编译后的世界里荡然无存。
但你的用户不活在编译时。
你的用户活在运行时。他在浏览器里输入一堆东西,点击提交,一个 HTTP 请求穿过互联网飞到你的服务器上。在这一路上,TypeScript 帮不了你任何忙。它不在那里。
这就好比你在家照了张证件照,确认了这是你。然后你把证件照放在桌子上,自己出了门。到了海关,人家要看证件,你拿不出来。你说"我在家确认过了,那个人就是我"。海关不会理你。
编译时检查是你的证件照。运行时检查是海关。
一套真正安全的系统,两条腿都要有。缺一条,就能摔得很难看。
TypeScript 是一条腿。Zod 是另一条。
Zod 不是魔法,是酒吧门口那个保安
想象你开了一家酒吧。
每天晚上十点,门口站着一个保安。他手里拿着你定下的规矩:不满18岁的不让进,假身份证的报警,穿拖鞋的不让进。
这个保安就是 Zod。
你写一段规则:
import { z } from 'zod'
const UserSchema = z.object({
username: z.string().min(1, '用户名不能为空').max(30, '用户名最多30个字符'),
password: z.string().min(8, '密码至少需要8位').max(128, '密码过长'),
email: z.string().email('请输入有效的邮箱地址'),
})
Zod 拿着这段规则,站在数据进入你系统的入口,一个一个地查。
但 Zod 这个保安有一个其他保安没有的绝活。
他在你招他的时候,顺便给你印了一叠"合格顾客"的档案卡。卡片上写着:username,string 类型,1到30个字符之间;password,string 类型,8到128个字符之间;email,string 类型,必须是合法邮箱格式。
TypeScript 一看到这叠档案卡,脸上挂上了满足的笑意。"懂了,"TypeScript 说,"我管编译时,你管运行时。"
然后你做这件事:
type UserInput = z.infer<typeof UserSchema>
// 结果:{ username: string; password: string; email: string }
z.infer 让 TypeScript 从 Zod 的规则里自动推导出类型。你不用手动写两遍。一条规则,编译时类型和运行时校验,一并产生。
这就是 Zod 和 TypeScript 的关系。不是竞争,是分工。一个管你写代码的时候别犯蠢,一个管你上线之后别有坏人乱来。
给你看一个真实的例子。
假设你在做一个用户注册 API。有人传了这样的 JSON 过来:
{
"username": "张三",
"password": "12",
"email": "not-an-email"
}
没有 Zod 的时候,你的后端大概这样写:
const { username, password, email } = req.body
// username 可能是空字符串,password 可能只有两位,
// email 根本不是一个合法邮箱
// 你他妈的得一个一个手动检查
if (!username || username.length > 30) {
/* ... */
}
if (!password || password.length < 8) {
/* ... */
}
if (email && !email.includes('@')) {
/* ... */
}
这段代码写起来烦,读起来更烦。而且如果产品经理说"用户名改成40字符",你要在一堆 if 里找到那一行。
有了 Zod 之后:
const result = userSchema.safeParse(req.body)
if (!result.success) {
// {
// success: false,
// error: {
// issues: [
// { path: ['password'], message: '密码至少需要8位' },
// { path: ['email'], message: '请输入有效的邮箱地址' }
// ]
// }
// }
return res.status(400).json({ details: result.error.flatten().fieldErrors })
}
// result.data 已经是被验证过的干净数据
// TypeScript 知道它的类型是 { username: string; password: string; email: string }
所有校验逻辑都在 Zod schema 里。所有错误信息也在那里。API 的逻辑只关心"验证通过之后做什么"。
这就是 Zod 把"验证"和"业务逻辑"分开了。
但 Zod 也有它不管的。
它不管数据存在哪里。它不管数据库里表怎么设计。它只管"进来的是什么数据、符不符合格式"。至于符合格式之后数据去哪,那是别的工具的事。
数据库那边,有个人叫 Prisma
你的数据和数据库之间,本来隔着一层语言障碍。
数据库说的是 SQL:CREATE TABLE、INSERT INTO、SELECT FROM。你的代码说的是 TypeScript:const user = { username: 'foo' }。
传统的做法是你在中间当翻译。你脑子里有一张"Users 表有哪些字段、每个字段是什么类型"的账本,然后在 TypeScript 里手动写 interface User \{ username: string \},手动写 SQL 查询,手动给每个字段标注类型。
翻译翻多了,迟早会翻错。
Prisma 做的事简单来说就是——它替你当翻译。你用一种接近人类语言的语法定义你的数据长什么样:
model User {
id String @id @default(cuid())
username String @unique @db.VarChar(30)
password String @db.VarChar(128)
email String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
然后敲一条命令:
npx prisma generate
Prisma 读你的定义,自动生成了这些东西:
- 一套 TypeScript 类型:
User、UserCreateInput、UserWhereInput - 一套数据库操作函数:
db.user.findUnique()、db.user.create()、db.user.update() - 每个函数都带着完整的类型提示
你现在可以做这种事:
const user = await db.user.findUnique({
where: { username: 'max' },
select: { id: true, username: true, createdAt: true },
})
// user 的类型自动推导为 { id: string; username: string; createdAt: Date } | null
你不用写任何类型标注。Prisma 从你的数据库定义里推导出了所有类型。
你改了 Prisma schema,加了个 avatarUrl 字段,重新运行 prisma generate。TypeScript 类型自动更新。所有用到 User 的地方都会在编译时报错——"你忘了传 avatarUrl"——直到你把该补的地方补上。
这就是 Prisma 的威力。它让数据库结构和 TypeScript 类型之间保持同步。
但注意,Prisma 生成的类型是"数据库中的数据类型",不是"用户输入数据的验证规则"。
UserCreateInput 告诉你 username 是 string。但它不保证这个 string 的长度在1到30之间。数据库的 @db.VarChar(30) 是物理限制,数据太长会报错。但如果 Zod 在前面就把超长的数据拦住了,用户会收到一个友好的错误提示,而不是数据库抛的乱码异常。
这就是 Zod 和 Prisma 之间的分工。Prisma 看数据库那头,Zod 看网络请求这头。中间需要一座桥。
一个叫 satisfies 的小东西,锁住了整座桥
现在我们有三样东西:
- Prisma 生成的
User类型——来自数据库 - 手写的 Zod schema——验证规则
z.infer提取的UserInput类型——来自验证规则
正常情况下,这三样东西各过各的,彼此不知道对方的存在。你删了 Prisma 里的一个字段,Zod schema 里还留着那个字段的验证规则。你加了 Zod 里的必填,Prisma 那头还是可空。
这不是能靠自觉维持的。你总会忘。
TypeScript 有一个叫 satisfies 的关键字,是救这件事的。
它的逻辑很简单:你告诉我 A 必须满足 B 的形状,但你不要把 A 的类型"抹成" B 的类型。保留 A 的详细信息。
写成代码:
import { type User } from '@prisma/client'
import { z } from 'zod'
export const userSchema = z.object({
username: z
.string()
.min(1, '用户名不能为空')
.max(30, '用户名最多30个字符')
.regex(/^[a-zA-Z0-9_]+$/, '用户名只能包含字母、数字和下划线'),
password: z.string().min(8, '密码至少需要8位').max(128, '密码过长'),
email: z.string().email('请输入有效的邮箱地址').optional().or(z.literal('')),
}) satisfies z.ZodType<Omit<User, 'id' | 'createdAt' | 'updatedAt'>>
注意最后一行。satisfies z.ZodType<Omit<User, 'id' | 'createdAt' | 'updatedAt'>> 的意思是:
"userSchema,你的形状必须能匹配 Prisma User 类型中除了 id、createdAt、updatedAt 之外的字段。"
如果哪天你在 Prisma 里把 username 删了,或者加了一个必填的 avatarUrl,或者把 email 从可选改成了必填——这行会马上报编译错误。
TypeScript 会告诉你:"你的 Zod schema 不再满足 User 类型了。去修。"
这就是那个双向锁。一端锁在 Prisma 定义上,一端锁在 Zod schema 上。你动哪一头,另一头都知道。
而且satisfies 不像 as 强制类型转换那样"抹平"信息。它检查完了就退到一边,让 z.infer 继续工作,提取出精确的字段定义和校验规则。
卧槽,这个小东西的妙处就在这里。它不做转换,只做检查。检查过了,各过各的。
如果不用 satisfies
你可以不开这道锁。
你觉得"我手写 Zod schema 和 Prisma 类型,靠自觉保持同步就行了"。
那你下次在 Prisma 里加了一个 avatarUrl String 必填字段之后会发生什么?
- Zod schema 不知道这个字段
- 前端表单不显示这个输入框——因为表单规则是根据 Zod schema 自动生成的
- 后端 API 不验证这个字段——同样是因为它用 Zod schema
- 数据库建表时新增了这一列,而且不是 nullable 的
结果是什么?用户提交的表单里没有 avatarUrl。Prisma 在做 INSERT 的时候报错:"Field 'avatarUrl' has no default value."
这是一个运行时错误。在你部署上线、第一个用户提交表单之后才会炸。
satisfies 把这个问题提前到了编译时。你加完字段,保存文件,TypeScript 马上报错。你甚至不用运行代码就知道缺了东西。
这不仅是省力。这是在把你的"自觉"换成编译器的强制检查。
翻译官二号:让 Zod 和你的 UI 对话
前端表单是数据进入系统的第一道门。
在 Ant Design 里,表单验证规则长这样:
rules={[
{ required: true, message: '用户名不能为空' },
{ min: 1, message: '用户名至少1个字符' },
{ max: 30, message: '用户名最多30个字符' },
]}
而 Zod schema 长这样:
username: z.string().min(1, '用户名不能为空').max(30, '用户名最多30个字符')
这两套格式在讲同一件事。但 Ant Design 听不懂 Zod 的格式。
需要一个人站在中间,把 Zod 说的话翻译成 Ant Design 能懂的话。
这个人我们叫他"适配器"。不用怕这个词——说得粗糙点,它就是一个函数:
import { type ZodObject, type ZodType } from 'zod'
interface AntdValidatorRule {
validator: (rule: unknown, value: unknown) => Promise<void>
message?: string
}
const createZodFormValidator = <T extends Record<string, unknown>>(
schema: ZodObject<T>,
): Record<keyof T, AntdValidatorRule[]> => {
const rules = {} as Record<keyof T, AntdValidatorRule[]>
for (const key in schema.shape) {
const fieldSchema = schema.shape[key as keyof T]
rules[key] = [
{
validator: async (_rule, value) => {
if (value === undefined || value === null || value === '' || value === ' ') {
if (fieldSchema.isOptional && fieldSchema.isOptional()) {
return Promise.resolve()
}
return Promise.reject(new Error('此字段为必填项'))
}
const result = await fieldSchema.safeParseAsync(value)
if (!result.success) {
const issue = result.error.issues[0]
return Promise.reject(new Error(issue.message))
}
return Promise.resolve()
},
},
] as AntdValidatorRule[]
}
return rules
}
你看这个函数做的事很简单:把 Zod schema 的 shape 一层一层剥开,给每个字段生成一个 Ant Design 能懂的 validator 函数。空值就先判断是不是可选字段;有值就跑 Zod 校验,失败就把 Zod 的错误信息直接吐给 Ant Design。
这个适配器你写一次就够了。
所有的表单,不管是注册、登录、修改密码、编辑资料——只要用了同一个 Zod schema 作为数据源,同一个 createZodFormValidator 函数来生成规则——就不需要再手动写验证了。
改了 Zod schema 里的一个限制,所有表单自动更新。
你不用担心"改了这个表单忘了那个表单"。在它们的视角里,根本没有"这个表单"和"那个表单"的区别——它们都指向同一个东西。
嵌套对象怎么办
上面的适配器处理的是扁平结构。但真实项目里,数据经常是嵌套的。
比如你有一个"用户注册"表单,里面除了基本字段,还有一个"地址"子对象:
const addressSchema = z.object({
city: z.string().min(1, '城市不能为空'),
street: z.string().min(1, '街道不能为空'),
zipCode: z.string().regex(/^\d{6}$/, '邮政编码格式不正确'),
})
const userWithAddressSchema = z.object({
username: z.string().min(1),
password: z.string().min(8),
address: addressSchema,
})
在 Ant Design 里,你用 Form.Item name=\{['address', 'city']\} 来绑定嵌套路径。
适配器需要能递归处理这种情况。核心改动是在遍历 schema.shape 时判断:如果某个字段本身也是一个 ZodObject,就递归进去:
const createRulesRecursive = (schema: ZodType, prefix: string[] = []): Record<string, AntdValidatorRule[]> => {
const rules: Record<string, AntdValidatorRule[]> = {}
if (schema instanceof z.ZodObject) {
for (const key in schema.shape) {
const fieldSchema = schema.shape[key]
const path = [...prefix, key].join('.')
if (fieldSchema instanceof z.ZodObject) {
Object.assign(rules, createRulesRecursive(fieldSchema, [...prefix, key]))
} else {
rules[path] = [createSingleFieldValidator(fieldSchema, key)]
}
}
}
return rules
}
这之后你就可以这么用:
const rules = createRulesRecursive(userWithAddressSchema)
// rules['username'] -> Ant Design rules for username
// rules['address.city'] -> Ant Design rules for city
// rules['address.street'] -> Ant Design rules for street
嵌套对象不难处理。但你在动手写适配器的时候,把这一点提前考虑到,会比以后改回来省很多力。
两种选择:手动 vs 自动生成
上面那套方案要求你手动写 Zod schema。
如果你连这一步都不想手动,还有一个选择:prisma-zod-generator。这个东西能直接在 Prisma 的 model 定义里写验证规则,然后自动生成 Zod schema。
你在 Prisma schema 里写:
generator zod {
provider = "prisma-zod-generator"
output = "../src/shared/generated-schemas"
isGenerateSelect = false
isGenerateInclude = false
}
model User {
id String @id @default(cuid())
/// @zod.string.min(1, { message: "用户名不能为空" })
/// @zod.string.max(30, { message: "用户名最多30个字符" })
username String @unique @db.VarChar(30)
/// @zod.string.min(8, { message: "密码至少需要8位" })
/// @zod.string.max(128)
password String @db.VarChar(128)
/// @zod.string.email({ message: "请输入有效的邮箱" }).optional()
email String? @db.VarChar(255)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
npx prisma generate 之后,自动生成:
// src/shared/generated-schemas/User.schema.ts
import { z } from 'zod'
export const UserModel = z.object({
id: z.string().cuid(),
username: z.string().min(1, { message: '用户名不能为空' }).max(30, { message: '用户名最多30个字符' }),
password: z.string().min(8, { message: '密码至少需要8位' }).max(128),
email: z.string().email({ message: '请输入有效的邮箱' }).optional(),
createdAt: z.date(),
updatedAt: z.date(),
})
省力是省力。但我不建议你一开始就用这个。
原因很简单:Prisma 注释里的 Zod 语法和原生 Zod 不完全一样。这个生成器支持的是 Zod 的一个子集。你迟早会碰到"这个 Zod 功能在注释里写不了"的情况。到那时候你是改回手写还是硬着头皮想变通办法?
先手写几个月的 Zod schema。等你真正理解了 Zod 能干什么、你的项目需要什么工具之后,再决定要不要引入这个生成器。
自动生成的代码你如果不理解它的行为,出了事你根本不知道怎么追。
说说后端,那个最后的守门员
不管前端验证做得多好,后端都必须再验一遍。
这不是前端的错。是"有坏人会跳过前端直接打你 API"这件事真的存在。
后端的验证逻辑很简单:
// src/app/api/register/route.ts
import { NextResponse } from 'next/server'
import { userSchema } from '@/shared/schemas/user.schema'
import { db } from '@/lib/db'
import bcrypt from 'bcrypt'
export async function POST(request: Request) {
try {
const json = await request.json()
const result = userSchema.safeParse(json)
if (!result.success) {
return NextResponse.json(
{
error: '输入数据格式错误',
details: result.error.flatten().fieldErrors,
},
{ status: 400 },
)
}
const { username, password, email } = result.data
const existingUser = await db.user.findUnique({
where: { username },
select: { id: true },
})
if (existingUser) {
return NextResponse.json({ error: '用户名已存在' }, { status: 409 })
}
const hashedPassword = await bcrypt.hash(password, 12)
const newUser = await db.user.create({
data: {
username,
password: hashedPassword,
email: email || null,
},
select: {
id: true,
username: true,
createdAt: true,
},
})
return NextResponse.json(
{
message: '注册成功',
user: { id: newUser.id, username: newUser.username },
},
{ status: 201 },
)
} catch (error) {
console.error('注册失败:', error)
return NextResponse.json({ error: '服务器内部错误' }, { status: 500 })
}
}
注意这里的一个细节:用 safeParse 而不是 parse。
parse 在验证失败的时候会直接抛异常。你的错误处理逻辑要放进 try-catch 里,和网络异常、数据库异常混在一起。
safeParse 不抛异常。它返回一个结果对象:\{ success: true, data: \{...\} \} 或 \{ success: false, error: \{...\} \}。你可以用 if 分支处理验证失败的情况,和业务异常分开。
这是小细节,但在真实项目里它让你的错误处理逻辑干净很多。
还有一件事必须说:
密码绝对不要存明文。
上面那段代码用 bcrypt.hash(password, 12)。bcrypt 是一种专门用来哈希密码的算法。它慢——但这正是它安全的原因。密码哈希就该慢,慢到暴力破解的成本高得让他不划算。
我的建议是 salt rounds 设成 12。越高越安全,但注册和登录的速度会明显变慢。12 在现在的硬件上是一个合理的平衡点。
拼图完成,退后一步看全景
所有的零件都摆在你面前了。现在退后一步,看看这些零件是怎么拼成一张完整的图的。
你的 Zod schema 文件是这座系统的"心跳"。
一条数据规则——"用户名不能空、不能超过30字符、只能是字母数字和下划线"——在这个文件里只定义了一次。
从它出发,有三条线:
第一条线,走向前端。 createZodFormValidator(userSchema) 把规则翻译成 Ant Design 的格式。用户还没提交,表单就开始验证了。打字的时候下面就有提示。
第二条线,走向后端。 userSchema.safeParse(requestBody) 在 API 入口处把数据过一遍。不管请求是从浏览器表单来的,还是从 curl 来的,还是从 Postman 来的——都一样验。
第三条线,走向数据库。 satisfies z.ZodType<User> 保证 Zod schema 和 Prisma 类型永远一致。你在 Prisma 里加了字段,TypeScript 就报 Zod schema 缺字段。你不改,就编译不过去。
前端是体验,提前告诉用户哪里填错了。后端是安全,拦住所有来源的垃圾数据。数据库是底线,物理层面上拒绝不合规的数据。
三层,三种验证方式,但是同一个源头。
产品经理下次再来,说"密码改成10位"的时候:
你打开 src/shared/schemas/user.schema.ts,把 .min(8) 改成 .min(10)。保存。然后去冲杯咖啡。
前端表单、后端 API、测试用例,不需要你手动改任何东西。所有的变化自动传播。
这不是魔法。这是一个设计选择:把散落在各处的验证逻辑收拢到一个地方,然后用工具链把这一个地方跟所有需要它的地方连起来。
你真的该写个测试
这套方案有一个很舒服的副产品:验证逻辑变得特别好测。
因为你所有的验证规则都在 Zod schema 里,测试就是往 userSchema.safeParse() 里丢数据然后看结果:
import { userSchema } from '@/shared/schemas/user.schema'
describe('userSchema', () => {
it('空用户名应该被拒绝', () => {
const result = userSchema.safeParse({
username: '',
password: 'password123',
email: 'test@example.com',
})
expect(result.success).toBe(false)
expect(result.error?.issues[0].path).toEqual(['username'])
})
it('无效邮箱应该被拒绝', () => {
const result = userSchema.safeParse({
username: 'testuser',
password: 'password123',
email: 'not-an-email',
})
expect(result.success).toBe(false)
expect(result.error?.issues[0].path).toEqual(['email'])
})
it('缺少可选邮箱应该通过', () => {
const result = userSchema.safeParse({
username: 'testuser',
password: 'password123',
})
expect(result.success).toBe(true)
})
it('有效数据应该全部通过', () => {
const result = userSchema.safeParse({
username: 'testuser',
password: 'password123',
email: 'test@example.com',
})
expect(result.success).toBe(true)
expect(result.data).toEqual({
username: 'testuser',
password: 'password123',
email: 'test@example.com',
})
})
})
不需要启动数据库。不需要 mock HTTP 请求。不需要渲染 React 组件。
纯函数。快速。可靠。
产品经理每次改需求,你改 Zod schema,跑一遍测试,全部通过。你就有信心上线了。
如果你现在已经有项目在跑了
上面所有的例子都是从零开始。但你可能在想:"我项目已经写了一年了,怎么加这套东西?"
答案是:你不用一次性全部翻新。
先找一个最让你头疼的表单——那个"产品经理改了好几次、每次你都要改三个文件"的表单。只给它写一个 Zod schema,只给它接适配器。
让这套方案先在一个角落里跑起来。
你会发现几件事:
第一,Zod 的运行时校验比手写 if 语句快,而且更不容易漏。你会想在其他地方也用。
第二,satisfies 会把一些你早就忘了但一直没发现的不一致暴露出来。比如你半年前在数据库加了 phoneNumber 字段,但后端校验从来没加过。不是因为它不重要,是因为你忘了。
第三,当你下次又接到需求变更,只改了一个文件就收工的时候,你会想把这个模式推广到整个项目。
推广不需要一次性完成。一个表单独接,一个一个来。接口一致,互不影响。
这种渐进式迁移比"停下来重构一个月"靠谱得多。
几个你大概率会遇到的坑
好话说了这么多,来点真实的。
这套方案不是银弹。有一些事情它确实不解决,你需要自己判断。
第一个坑:satisfies 报错信息不够友好。
当你的 Prisma schema 和 Zod schema 不匹配时,TypeScript 给出的错误信息是"类型不匹配"四个字,以及一堆层层嵌套的泛型展开。它不会贴心地告诉你"嘿,你忘了在 Zod schema 里加 avatarUrl 字段了"。
第一次遇到这种报错的时候,你需要有耐心去读那些泛型垃圾堆。多遇到几次,你就知道这个报错在说什么了。
第二个坑:嵌套对象需要扩展适配器。
上面给的那个 createZodFormValidator 能处理扁平结构。如果你的表单里嵌套了对象——比如用户的 address 字段是一个 \{ city, street, zipCode \} 对象——你需要让适配器能递归处理。
不复杂,但需要你自己写。我没办法给你一个适配所有 UI 框架的万能适配器。
第三个坑:Zod 的默认错误消息是英文的。
你写 z.string().min(1),Zod 的默认错误消息是 "String must contain at least 1 character(s)"。
你的用户在中文页面上看到这个,一脸蒙逼。
治这个的办法是在每个 Zod 方法里手动写消息:z.string().min(1, '用户名不能为空')。或者在适配器的 formatErrorMessage 函数里做一个错误码到中文的映射表。
前者更精确,后者更省力。
第四个坑:prisma-zod-generator 的注释语法有边界。
Prisma 注释里的 /// @zod.string.min(1) 和原生 Zod 里的 z.string().min(1) 不完全对等。生成器只支持一个子集。
.refine() 自定义校验?可能不行。
.transform() 数据转换?不一定支持。
.superRefine() 多字段交叉校验?通常不支持。
你需要这些功能的时候,自动生成这条路就走不通了。
第五个坑:这套方案不解决 API 设计问题。
Zod + Prisma 管的是数据校验和数据库操作。API 的路由设计、接口的 REST 风格、版本号怎么切——这些事情依然需要你自己想。
第六个坑:性能。
Zod 的校验不是免费的。对于简单的字符串长度检查,开销可以忽略。但如果你在 API 的热路径上用 Zod 做复杂的嵌套对象校验、或者用 .refine() 做数据库查询——那就要掂量一下了。
一般来说,Zod 的快慢不在你的性能瓶颈上。你的性能问题大概率在数据库查询优化、前端渲染优化这些地方。Zod 这层的开销很少成为真正的问题。
但你知道这个可能性就行了。
一套你自己的判断框架
读别人的文章,最怕的就是"懂了,但不知道什么时候该用"。
我给你一套很简单的问题。下次你再考虑要不要上这套方案的时候,问自己这几件事。
问题一:你的应用有几个地方在定义"同一段验证规则"?
如果答案是"至少两个"——比如前端有、后端有——那 Zod 值得上。
如果你只有一个后端、没有前端表单(纯 API 服务),那 Zod 在后端就够了,不需要适配器那部分。
问题二:你的产品经理改需求的频率高吗?
如果高,单一真相来源的价值会指数级放大。每改一次,你只需要动一个文件。
如果半年不改一次需求,那这套方案的初始成本可能不划算。
问题三:手写 Zod schema 还是用 prisma-zod-generator 自动生成?
一个简单的判断:如果你刚接触这套东西,手写。等你对 Zod 的行为有足够的直觉之后,再看要不要用生成器。
手写三个月,你就知道生成器帮你省了什么、又给你加了什么限制。
问题四:你是不是在做一个需要 UI 表单的项目?
如果是,适配器模式值得写。如果是一个 Headless CMS 或者纯后端项目,那 Prisma + Zod 就够了。
问题五:你团队里有没有"数据库改了但忘了改前端验证"的血泪史?
如果有,satisfies 那一步你必须要加。它是杜绝这类事故的最后一道防线。
问题六:你投入这套方案的时间真的划得来吗?
初始成本:写 Zod schema(半小时到一个小时)、写适配器(一小时)、配置 Prisma generator(十分钟)。总共大概两个小时。
回报:每次需求变更,从一个一个文件改三处变成改一个文件。这个数字你根据自己项目的需求变更频率算一下。
我的判断是:只要你的项目还要维护超过三个月,两个小时的初始投入远远值得。
一个安静的结尾
写到这里,我又想起了两年前的那个下午。
三个文件,三处改动,改了十分钟,心里那个荒谬的感觉在改完之后又多停留了一会儿。
那感觉不是"这该怎么解决?"。解决方法是明显的——Zod 和 Prisma 和 TypeScript 这几样东西分别出现很久了。
那感觉更像是——我他妈的为什么用了这么久才意识到这是一个问题?
不是项目太复杂。不是技术不成熟。是在每一个着急上线的下午,你都觉得"算了就在这个 Form.Item 里手写一下规则吧,等这个版本发完再重构"。然后这个版本发完了,下一个版本来了,又有一个下午。
这套方案的对手不是 GraphQL,不是 tRPC,不是 Hasura。那些东西解决的是另外的问题。
它的对手是你的懒惰,和你对"暂时的技术债"的容忍。
只要你在某一次需求变更时,觉得"就这个表单我手动写一下,不用动 schema 了"——这条线就断了。
管住自己,这套东西就能管住你的代码。
读者来信
暂无来信,期待你的分享。