Max
搜索
返回故事会

Prisma + TypeScript + Zod 打造端到端类型安全

39 分钟阅读0Max ZhangBackend
TypeScriptPrisma

那天下午,产品经理又来了

那次是真的有点卧槽。

我在做一个用户注册系统。Prisma 管数据库,Next.js 跑前后端,Ant Design 画表单。功能跑得好好的,代码也干净。

产品经理走过来,拉了把椅子坐下。"用户名限制改成30个字符吧,原来是20个。密码最短改成8位。还有邮箱改成必填。"

我说行,五分钟的事。

然后我打开了三个文件。

第一个是 schema.prisma。找到 User model,把 @db.VarChar(20) 改成 @db.VarChar(30),把 password 的注释从 .min(6) 改成 .min(8),把 emailString? 改成 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 类型:UserUserCreateInputUserWhereInput
  • 一套数据库操作函数: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 告诉你 usernamestring。但它不保证这个 string 的长度在1到30之间。数据库的 @db.VarChar(30) 是物理限制,数据太长会报错。但如果 Zod 在前面就把超长的数据拦住了,用户会收到一个友好的错误提示,而不是数据库抛的乱码异常。

这就是 Zod 和 Prisma 之间的分工。Prisma 看数据库那头,Zod 看网络请求这头。中间需要一座桥。

一个叫 satisfies 的小东西,锁住了整座桥

现在我们有三样东西:

  1. Prisma 生成的 User 类型——来自数据库
  2. 手写的 Zod schema——验证规则
  3. 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'>&gt;

注意最后一行。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 必填字段之后会发生什么?

  1. Zod schema 不知道这个字段
  2. 前端表单不显示这个输入框——因为表单规则是根据 Zod schema 自动生成的
  3. 后端 API 不验证这个字段——同样是因为它用 Zod schema
  4. 数据库建表时新增了这一列,而且不是 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) =&gt; Promise&lt;void&gt;
  message?: string
}

const createZodFormValidator = &lt;T extends Record&lt;string, unknown&gt;&gt;(
  schema: ZodObject&lt;T&gt;,
): Record&lt;keyof T, AntdValidatorRule[]&gt; =&gt; {
  const rules = {} as Record&lt;keyof T, AntdValidatorRule[]&gt;

  for (const key in schema.shape) {
    const fieldSchema = schema.shape[key as keyof T]

    rules[key] = [
      {
        validator: async (_rule, value) =&gt; {
          if (value === undefined || value === null || value === '' || value === ' ') {
            if (fieldSchema.isOptional &amp;&amp; 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', () =&gt; {
  it('空用户名应该被拒绝', () =&gt; {
    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('无效邮箱应该被拒绝', () =&gt; {
    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('缺少可选邮箱应该通过', () =&gt; {
    const result = userSchema.safeParse({
      username: 'testuser',
      password: 'password123',
    })
    expect(result.success).toBe(true)
  })

  it('有效数据应该全部通过', () =&gt; {
    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 了"——这条线就断了。

管住自己,这套东西就能管住你的代码。

读者来信

0/1000

暂无来信,期待你的分享。