《TypeScript编程实战》7.1 Prisma schema 与类型生成

本节把 Prisma 当作一条从数据库结构到 TypeScript 类型的单向流水线:写出第一个 schema.prisma,逐块拆解 generator、datasource 与 model 的字段类型与常用属性,观察 prisma generate 生成了什么,并说明 select、include 的返回类型如何推导。最后给出常见报错对照与 N+1 规避,读完你能独立设计可演进的数据模型。

本节目标:把 Prisma 从「一个 ORM」变成「一条从数据库结构到 TypeScript 类型的单向流水线」。读完后你能独立写出一份可用的 schema.prisma,说清 prisma generate 到底生成了什么,并解释为什么 Prisma Client 的查询返回值不需要你手写任何 interface。

7.1 Prisma schema 与类型生成

前六章我们把脚手架、HTTP 服务、依赖注入、配置与生命周期都搭起来了,但服务跑起来之后,数据总得落到某个地方。数据访问层是整条链路里类型最容易「漏」的一环:请求体有校验、响应有 DTO,唯独 SQL 查询的结果常常被 any 一笔带过。本章要解决的就是这最后一公里。

数据访问层的三条技术路线

TypeScript 在数据库边界上有三种典型的类型来源,理解它们的差异,才能理解 Prisma 的定位。

路线类型的来源代表工具主要代价
手写类型 + 原生驱动人pg、mysql2表结构一改,类型不跟着改,静默漂移
schema 生成类型数据库 schemaPrisma需要一次 codegen 步骤
查询推导类型查询语句本身Drizzle需要熟悉 SQL 与推导规则

Prisma 属于第二条路线。它的核心主张是:数据库结构是唯一事实来源,TypeScript 类型是它的投影。你不再手写 interface User,而是让工具从 schema.prisma 生成。下一节会讲第三条路线(Drizzle 的 SQL 式推导),它的思路与 Prisma 恰好相反,两者对照着看收益最大。

安装与初始化

先装命令行工具和运行时客户端。注意 prisma 是开发期工具,@prisma/client 是运行期依赖,两者版本必须一致,所以通常把 prisma 装成 devDependency:

pnpm add -D prisma
pnpm add @prisma/client
pnpm prisma init --datasource-provider postgresql

init 会在仓库根目录创建 prisma/schema.prisma 和 .env:

✔ Your Prisma schema was created at prisma/schema.prisma
  You can now open it in your favorite editor.
Next steps:
1. Set the DATABASE_URL in the .env file to point to your existing database.
2. Run prisma db pull to turn your database schema into a Prisma schema.
3. Run prisma generate to generate the Prisma Client.

生成的骨架长这样:

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

.env 里会写入一行占位连接串。这里有一个值得强调的设计取舍:url 用 env() 而不是硬编码。Prisma CLI 会自己读取 .env,而运行时由 @prisma/client 从 process.env 读取,两边都能拿到同一个值。连接串如何按环境分层、如何做类型化校验,属于配置管理的话题,见 2.2 环境变量与配置的类型化 。

一个常见的启动失败是这样的:

Error: P1013 The provided database string is invalid. Invalid URL: DATABASE_URL

原因通常不是连接串写错,而是 .env 文件所在目录与 schema.prisma 不一致——Prisma CLI 只在 schema.prisma 同目录及其上层查找 .env。把 .env 放在仓库根、schema 放在 prisma/ 下是最省事的布局。

逐块拆解 schema 文件

schema.prisma 由三种顶层块组成,语法刻意做得比 SQL DDL 更精简。

generator 块:生成什么、生成到哪

generator client {
  provider = "prisma-client-js"
  output   = "../src/generated/prisma"
}

provider 决定生成哪一套客户端。output 在 Prisma 5 之后变成可选,省略时生成到 node_modules/.prisma/client,再由 @prisma/client 转发。把 output 指到仓库内(如上面的 src/generated/prisma)有两个好处:生成产物进入版本控制便于 review,且在 monorepo 中不依赖 node_modules 提升(hoisting)的结果。monorepo 的目录约定见 2.1 路径别名与 monorepo 结构 。

datasource 块:连哪个库

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

provider 的取值是编译期常量,只影响 CLI 生成哪种 SQL 方言(postgresql、mysql、sqlite、sqlserver、mongodb、cockroachdb)。同一份 schema 换个 provider 并不能保证语义等价,比如 Json 类型在 SQLite 上就退化为字符串。

model 块:真正的类型来源

model User {
  id        String   @id @default(cuid())
  email     String   @unique
  name      String?
  role      Role     @default(USER)
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
  posts     Post[]

  @@index([createdAt])
  @@map("users")
}

enum Role {
  USER
  ADMIN
}

字段类型到数据库与 TypeScript 的映射关系,最好直接记成一张表:

Prisma 类型PostgreSQL 列类型生成的 TS 类型
Stringtextstring
Intintegernumber
BigIntbigintbigint
Floatdouble precisionnumber
DecimalnumericPrisma.Decimal
Booleanbooleanboolean
DateTimetimestamp(3)Date
JsonjsonbPrisma.JsonValue
BytesbyteaUint8Array

这张表里有两处最容易踩:

  • Decimal 生成的是 Prisma.Decimal(decimal.js 的实例),不是 number。直接参与 + 运算会得到字符串拼接般的意外结果,必须用 .plus()、.times() 等方法,或者在读出来之后显式 toNumber()。
  • BigInt 在 JSON.stringify 时会抛 TypeError: Do not know how to serialize a BigInt,序列化前要转成字符串。UUID 与自增 ID 的取舍见 UUID 标识符设计 。

属性:约束在 schema 里的表达

属性分两级:字段级用 @,模型级用 @@。

model Post {
  id        String    @id @default(uuid()) @db.Uuid
  title     String    @db.VarChar(200)
  body      String?
  published Boolean   @default(false)
  authorId  String    @map("author_id")
  author    User      @relation(fields: [authorId], references: [id], onDelete: Cascade)
  tags      String[]  @default([])

  @@unique([authorId, title])
  @@index([published, createdAt(sort: Desc)])
  @@map("posts")
}
属性作用备注
@id主键复合主键写成 @@id([a, b])
@default默认值cuid()、uuid()、now()、autoincrement()
@unique唯一约束也是 findUnique 可用的前提
@updatedAt写入时自动更新由客户端维护,不是数据库触发器
@map / @@map字段名/表名映射让 TS 用 camelCase、数据库用 snake_case
@relation声明外键onDelete 可选 Cascade/Restrict/SetNull

@updatedAt 值得单独说一句:它由 Prisma Client 在 update 时赋值,绕过 Prisma 直接写 SQL 不会触发。如果你需要数据库层面的强保证,应该改用数据库触发器。另外 @map 只改列名不改类型,而 @db.VarChar(200) 这类原生类型注解只对 PostgreSQL/MySQL 生效,切到 SQLite 会被忽略——这是 schema 可移植性的隐藏成本。

关系建模:三种基数

一对一用「一侧持有外键 + 另一侧声明可选反向关系」表达:

model Profile {
  id     String @id @default(cuid())
  bio    String?
  user   User   @relation(fields: [userId], references: [id])
  userId String @unique
}

userId 上的 @unique 是「一对一」与「一对多」的唯一区别——它把外键列变成唯一索引,从结构上禁止一个用户拥有两个 Profile。

一对多就是上面 User 与 Post 的形式:多的一侧持有外键字段,少的一侧声明数组。注意数组那一侧不产生数据库列,它只是 Prisma 用来做关联查询的元信息。

多对多有隐式和显式两种写法。隐式写法简洁,但代价是连接表完全由 Prisma 托管:

model Post {
  id         String     @id @default(cuid())
  categories Category[]
}

model Category {
  id    String @id @default(cuid())
  posts Post[]
}

隐式连接表的致命限制是不能携带额外字段。一旦你需要 addedAt(何时加入分类)或 sortOrder,就必须显式建模:

model PostCategory {
  postId     String
  categoryId String
  addedAt    DateTime @default(now())
  sortOrder  Int      @default(0)
  post       Post     @relation(fields: [postId], references: [id], onDelete: Cascade)
  category   Category @relation(fields: [categoryId], references: [id], onDelete: Cascade)

  @@id([postId, categoryId])
}

从隐式迁移到显式是破坏性变更,prisma migrate 会要求你手动确认数据搬迁。经验法则是:只要你对「关系本身」有任何属性诉求,一开始就写显式连接表。

prisma generate:类型究竟从哪里来

写完 schema,跑一次生成:

pnpm prisma generate
✔ Generated Prisma Client (v5.22.0) to ./node_modules/.prisma/client in 148ms

此刻 @prisma/client 导出的不再是一个泛型壳子,而是按你的 schema 逐字段生成的具体类型。可以验证一下推导结果:

import { PrismaClient } from '@prisma/client'

const prisma = new PrismaClient()

const user = await prisma.user.findUnique({ where: { id: 'u_1' } })
//    ^? const user: {
//         id: string; email: string; name: string | null;
//         role: Role; createdAt: Date; updatedAt: Date;
//       } | null

注意三个细节:name 是 string | null(对应 String?);role 是枚举类型 Role 而不是 string;整个返回值是 User | null,因为 findUnique 可能查不到。这些都不是手写的,而是 generate 从 schema 推出来的。

select 与 include 会进一步收窄返回类型:

const slim = await prisma.user.findUnique({
  where: { id: 'u_1' },
  select: { id: true, email: true },
})
//    ^? const slim: { id: string; email: string } | null

const withPosts = await prisma.user.findUnique({
  where: { id: 'u_1' },
  include: { posts: { where: { published: true }, take: 5 } },
})
//    ^? const withPosts: (User & { posts: Post[] }) | null

这意味着**「查询写窄一点,类型就自动窄一点」**。当你想把查询结果作为参数传给别的函数时,不需要为每种投影手写 DTO,直接用 Prisma.UserGetPayload 提取即可:

type UserWithPosts = Prisma.UserGetPayload<{
  include: { posts: true }
}>

function renderProfile(user: UserWithPosts): string {
  return `${user.email} 有 ${user.posts.length} 篇草稿`
}

这个 GetPayload 是本章最实用的一个工具:它把「查询形状」变成了可复用的类型,让 DAL 的返回类型与服务层签名严丝合缝。若你想进一步收敛成对外契约,可以在这层之上再接一层校验,见 TypeScript 与 Zod 校验 。

生成产物的边界:Prisma 不管什么

Prisma 生成的是结构类型,不是业务约束。它知道 email 是 string,但不知道它必须符合邮箱格式;它知道 posts 是数组,但不知道业务上「草稿不得超过 50 篇」。这类约束要么写进数据库(CHECK 约束、触发器),要么写进服务层的校验,Prisma 层不适合承担。

另一个边界是原生 SQL。当查询复杂到 Prisma 的 API 表达不了时,用 $queryRaw 逃生:

const rows = await prisma.$queryRaw<{ month: string; total: bigint }[]>`
  SELECT to_char(created_at, 'YYYY-MM') AS month, count(*) AS total
  FROM users GROUP BY 1 ORDER BY 1
`

注意这里必须手动标注泛型,因为模板字符串里的 SQL 对类型系统是不透明的——这是 Prisma 类型安全链条上唯一的破口,也是它相对 Drizzle 的短板。$queryRaw 的参数会用占位符绑定,不要用 $queryRawUnsafe 拼接字符串,否则会引入注入风险,相关分析见 SQL 注入防护 。

常见坑与报错对照

现象原因处理
Property 'user' does not exist on type 'PrismaClient'改完 schema 没跑 generate重跑 pnpm prisma generate
PrismaClientInitializationError: ... Can't reach database server数据库没起或连接串错检查 DATABASE_URL 与容器状态
Argument 'where' of type UserWhereUniqueInput needs at least one of id or emailwhere 里用了非唯一字段改用 findFirst 或加 @unique
Unknown argument 'include'同时写了 select 与 include二选一,或把关联塞进 select
Decimal 参与运算结果诡异忘了它是 decimal.js 对象显式 toNumber() 或调用其方法

还有一种不报错但很贵的坑:N+1。下面这段代码对 100 个用户会发 101 条查询:

const users = await prisma.user.findMany()
for (const u of users) {
  const count = await prisma.post.count({ where: { authorId: u.id } })
  console.log(u.email, count)
}

正确写法是用一次 include 或 _count:

const users = await prisma.user.findMany({
  include: { _count: { select: { posts: true } } },
})
for (const u of users) {
  console.log(u.email, u._count.posts) // 共 1 条 SQL
}

排查这类问题的通用手段是打开查询日志,观察 query 事件的数量:

const prisma = new PrismaClient({ log: [{ emit: 'event', level: 'query' }] })
prisma.$on('query', (e) => {
  console.log(e.duration, e.query)
})

与迁移的衔接

generate 只负责「schema → 类型」,它不碰数据库。让数据库结构与 schema 对齐是另一条命令链:prisma migrate dev(开发期,生成 SQL 迁移文件并应用)、prisma migrate deploy(生产期,只应用已有迁移)、prisma db push(不产生迁移文件,直接改结构,仅适合原型)。三者的边界、灰度发布与回滚策略放在 7.3 迁移、事务与连接池 里展开。

如果你已经有一个存量数据库,第一步不是写 schema,而是反向拉取:

pnpm prisma db pull
pnpm prisma generate

db pull 会把现有表结构转成 schema.prisma,之后就以文件为准。这条路径特别适合接手老项目,但要注意它会丢失 Prisma 无法表达的数据库特性(如部分索引、CHECK 约束)。

下一节我们换一条完全不同的思路:不生成客户端,而是让类型从 SQL 语句本身推导出来。

小结

这一节我们把 Prisma 的「单向流水线」拆开看了一遍:

  • schema.prisma 是唯一事实来源,generator、datasource、model 三块分别回答「生成什么」「连哪个库」「有哪些表和字段」。
  • 字段类型到 TS 类型的映射是自动的,但 Decimal、BigInt、Date 三处有反直觉的行为,必须在设计阶段就想清楚。
  • prisma generate 把 schema 编译成具体类型,select、include 会同步收窄返回值,Prisma.XxxGetPayload 让查询形状可以复用为类型。
  • $queryRaw 是类型安全的唯一破口,需要手动标注泛型,且要避免 $queryRawUnsafe 带来的注入风险。
  • generate 只管类型,改库结构要靠 migrate 系列命令,二者的职责不要混淆。

Prisma 的强项是「你不用懂 SQL 也能拿到类型」,代价是复杂查询的表达力和原生 SQL 的类型安全。下一节我们要看的 Drizzle 正好反过来:它把 SQL 交还给你,同时用推导规则把类型补上。

阅读导航:上一节:6.3 配置与生命周期 · 下一节:7.2 Drizzle 的 SQL 式类型推导 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes