本节目标:把 Prisma 从「一个 ORM」变成「一条从数据库结构到 TypeScript 类型的单向流水线」。读完后你能独立写出一份可用的
schema.prisma,说清prisma generate到底生成了什么,并解释为什么 Prisma Client 的查询返回值不需要你手写任何interface。
7.1 Prisma schema 与类型生成
前六章我们把脚手架、HTTP 服务、依赖注入、配置与生命周期都搭起来了,但服务跑起来之后,数据总得落到某个地方。数据访问层是整条链路里类型最容易「漏」的一环:请求体有校验、响应有 DTO,唯独 SQL 查询的结果常常被 any 一笔带过。本章要解决的就是这最后一公里。
数据访问层的三条技术路线
TypeScript 在数据库边界上有三种典型的类型来源,理解它们的差异,才能理解 Prisma 的定位。
| 路线 | 类型的来源 | 代表工具 | 主要代价 |
|---|---|---|---|
| 手写类型 + 原生驱动 | 人 | pg、mysql2 | 表结构一改,类型不跟着改,静默漂移 |
| schema 生成类型 | 数据库 schema | Prisma | 需要一次 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 类型 |
|---|---|---|
String | text | string |
Int | integer | number |
BigInt | bigint | bigint |
Float | double precision | number |
Decimal | numeric | Prisma.Decimal |
Boolean | boolean | boolean |
DateTime | timestamp(3) | Date |
Json | jsonb | Prisma.JsonValue |
Bytes | bytea | Uint8Array |
这张表里有两处最容易踩:
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 email | where 里用了非唯一字段 | 改用 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 式类型推导 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。