本节目标:理解 Drizzle 与 Prisma 的路线差异——它不做代码生成,表结构写在 TypeScript 文件里,类型随查询语句自然推导。读完后你能用
pgTable定义一套可迁移的表结构,说清$inferSelect与$inferInsert为什么不同,并能在select、join、关系查询三种写法之间做出正确选择。
7.2 Drizzle 的 SQL 式类型推导
上一节的 Prisma 走的是「先描述、后生成」:schema.prisma 是一份独立 DSL,prisma generate 把它编译成 TypeScript 类型。Drizzle 的思路完全相反——表结构就是普通的 TypeScript 代码,类型由查询本身推导出来。没有 codegen 步骤,也没有第二份需要同步的文件。
两条路线的正面对照
先建立整体印象,再展开细节:
| 维度 | Prisma | Drizzle |
|---|---|---|
| 表结构载体 | schema.prisma(独立 DSL) | .ts 文件(pgTable) |
| 类型来源 | prisma generate 生成 | 从表定义与查询推导 |
| 是否需要 codegen | 需要,改 schema 必跑 | 不需要,TS 编译器直接读 |
| 查询风格 | 对象式 API(findMany) | 贴近 SQL 的链式 API |
| 复杂查询表达力 | 受限于 API,需 $queryRaw 逃生 | 天然贴近 SQL,sql 模板兜底 |
| 类型安全的破口 | 原生 SQL 处必须手标泛型 | 同样是原生 SQL 处 |
| 学习曲线 | 低(不懂 SQL 也能用) | 中(需要懂 SQL 与 join) |
一句话总结:Prisma 帮你隐藏 SQL,Drizzle 帮你写出 SQL 并把类型补上。如果你的团队 SQL 功底扎实、查询复杂度高,Drizzle 的收益更明显;如果你希望业务代码完全不出现 SQL 概念,Prisma 更省心。两者的横向比较在 Node.js ORM 选型对比 中有更完整的展开。
安装与配置
Drizzle 拆成两个包:运行时的 drizzle-orm 和开发期的 drizzle-kit(负责生成迁移、推结构、开 Studio)。驱动可以自选,这里用 postgres(postgres.js):
pnpm add drizzle-orm postgres
pnpm add -D drizzle-kit
然后在仓库根写 drizzle.config.ts。它只被 drizzle-kit 读取,不参与运行时:
import { defineConfig } from 'drizzle-kit'
export default defineConfig({
schema: './src/db/schema.ts',
out: './drizzle',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL!,
},
})
这里出现的 process.env.DATABASE_URL! 是本章唯一一处非空断言。更严谨的做法是先用校验库解析环境变量再导出,避免连接串为空时在启动阶段才炸,做法见 2.2 环境变量与配置的类型化
。
连接实例通常只创建一次,并挂到依赖注入容器上,避免每个请求都新建连接池:
import { drizzle } from 'drizzle-orm/postgres-js'
import postgres from 'postgres'
import * as schema from './schema'
const client = postgres(process.env.DATABASE_URL!, { max: 10 })
export const db = drizzle(client, { schema })
注意 { schema } 这个参数:关系查询 API 依赖它,不传的话 db.query.users 会是 undefined。这是新手最常见的第一个报错来源。
用 TypeScript 描述表结构
表定义是纯 TypeScript,没有新语法要学。下面这张表覆盖了最常见的列类型:
import {
pgTable, text, uuid, boolean, timestamp, numeric,
pgEnum, index, uniqueIndex,
} from 'drizzle-orm/pg-core'
export const roleEnum = pgEnum('role', ['user', 'admin'])
export const users = pgTable(
'users',
{
id: uuid('id').primaryKey().defaultRandom(),
email: text('email').notNull().unique(),
name: text('name'),
role: roleEnum('role').notNull().default('user'),
balance: numeric('balance', { precision: 12, scale: 2 }).notNull().default('0'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
index('users_created_at_idx').on(t.createdAt),
uniqueIndex('users_email_lower_idx').on(t.email),
],
)
这里有三个关键约定:
- 列名与字段名分离。
email: text('email')里,键是 TypeScript 属性名,字符串参数是数据库列名。要写成 snake_case 列名只需createdAt: timestamp('created_at'),TS 侧依然是 camelCase。 .notNull()直接决定类型。写了就是string,不写就是string | null。这个映射没有任何隐式规则,读 schema 就能推出类型。- 索引放在第三个参数的数组里。旧版本要求返回对象,0.36 之后推荐返回数组;返回对象在类型上会报
Expected an array。
外键与一对多关系:
export const posts = pgTable('posts', {
id: uuid('id').primaryKey().defaultRandom(),
title: text('title').notNull(),
published: boolean('published').notNull().default(false),
authorId: uuid('author_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
})
.references(() => users.id) 里的箭头函数是延迟求值的,用来打破 users 与 posts 互相引用的循环。忘了写成箭头函数会得到 Block-scoped variable 'users' used before its declaration。
类型推导的核心:$inferSelect 与 $inferInsert
表定义本身就是一个类型载体,用 $inferSelect 把行类型抽出来:
type User = typeof users.$inferSelect
// {
// id: string
// email: string
// name: string | null
// role: 'user' | 'admin'
// balance: string
// createdAt: Date
// }
type NewUser = typeof users.$inferInsert
// {
// id?: string
// email: string
// name?: string | null
// role?: 'user' | 'admin'
// balance?: string
// createdAt?: Date
// }
两者唯一的差别是可选性:$inferSelect 描述「从数据库读出来的一行」,所有非空列都必填;$inferInsert 描述「准备写进去的一行」,凡是有默认值或有 defaultRandom() 的列都变成可选。这就是为什么插入时不用提供 id 和 createdAt。
注意 balance 的类型是 string 而不是 number。PostgreSQL 的 numeric 精度可以超过 IEEE 754 双精度浮点,驱动默认以字符串返回以避免精度丢失,Drizzle 忠实继承了这一点。做金额计算时要显式用 decimal 库,不要 Number(row.balance)。
枚举列 role 推导成字面量联合 'user' | 'admin',而不是 string。这一点比 Prisma 的枚举更彻底——Prisma 生成的是 Role 枚举对象,Drizzle 直接给你字面量联合,可以参与 switch 的穷尽性检查。
查询:类型随投影收窄
最基础的查询是「取全表全部列」:
import { eq, and, gt, inArray, desc } from 'drizzle-orm'
const all = await db.select().from(users)
// ^? const all: { id: string; email: string; name: string | null; ... }[]
一旦传入投影对象,返回类型立刻变成投影的形状——这正是 Drizzle 最有价值的地方:
const slim = await db
.select({ id: users.id, email: users.email })
.from(users)
.where(eq(users.role, 'admin'))
// ^? const slim: { id: string; email: string }[]
where 里的 eq(users.role, 'admin') 是类型安全的:第二个参数被约束为 'user' | 'admin',写成 'administrator' 会直接编译失败。常用的操作符有 eq、ne、gt、gte、lt、lte、inArray、like、isNull,组合用 and、or、not:
const active = await db
.select({ id: users.id })
.from(users)
.where(
and(
inArray(users.role, ['user', 'admin']),
gt(users.createdAt, new Date('2026-01-01')),
),
)
.orderBy(desc(users.createdAt))
.limit(20)
.offset(40)
连接查询的推导规则值得单独记住:innerJoin 不会改变列的可空性,leftJoin 会把右侧所有列变成可空。
const rows = await db
.select({ title: posts.title, email: users.email })
.from(posts)
.innerJoin(users, eq(posts.authorId, users.id))
// ^? const rows: { title: string; email: string }[]
const maybe = await db
.select({ title: posts.title, email: users.email })
.from(posts)
.leftJoin(users, eq(posts.authorId, users.id))
// ^? const maybe: { title: string; email: string | null }[]
这个规则是纯类型层面的:数据库返回的行确实可能没有匹配的 user,所以 email 必须是 string | null。如果你的代码里写了 maybe[0].email.toLowerCase(),编译器会拦住你——这正是我们想要的效果。
需要聚合或窗口函数时,用 sql 模板并手动标注结果类型:
import { sql } from 'drizzle-orm'
const stats = await db
.select({
month: sql<string>`to_char(${users.createdAt}, 'YYYY-MM')`,
total: sql<number>`count(*)::int`,
})
.from(users)
.groupBy(sql`1`)
.orderBy(sql`1`)
sql<number> 里的泛型是唯一的类型断点:Drizzle 无法推断 SQL 表达式的结果类型,必须由你保证。count(*) 在 PostgreSQL 里返回 bigint,驱动会给字符串,所以这里显式 ::int 转成整数,再声明为 number。忘记转换会导致运行时拿到 "42" 而类型系统认为是 42——这类「类型撒谎」是最难排查的 bug。
关系查询 API:一次查询取回嵌套结构
手写 join 会把嵌套结构压平成扁平行,前端通常不想要。Drizzle 提供关系查询 API 来还原嵌套:
import { relations } from 'drizzle-orm'
export const usersRelations = relations(users, ({ many }) => ({
posts: many(posts),
}))
export const postsRelations = relations(posts, ({ one }) => ({
author: one(users, {
fields: [posts.authorId],
references: [users.id],
}),
}))
之后就能这样查:
const nested = await db.query.users.findMany({
with: { posts: { where: eq(posts.published, true), limit: 5 } },
limit: 10,
})
// ^? const nested: ({ ...User } & { posts: { ...Post }[] })[]
with 的嵌套形状会完整体现在类型里,且它是一条 SQL(内部用 lateral join 或 JSON 聚合实现),不是 N+1。这一点比手写循环查询安全得多。
需要提醒的是:关系查询的 where 只能作用在「关系定义已经声明过」的字段上。如果你没写 usersRelations,db.query.users.findMany({ with: { posts: true } }) 会在类型层直接报错,而不是运行时才发现。
迁移:drizzle-kit 的四条命令
Drizzle 不做运行时类型生成,但迁移仍然需要工具。四条命令的职责边界很清楚:
| 命令 | 作用 | 使用场景 |
|---|---|---|
drizzle-kit generate | 对比 schema 与快照,生成 .sql 迁移文件 | 提交迁移,走 review |
drizzle-kit migrate | 按顺序应用未执行的迁移 | 本地与生产部署 |
drizzle-kit push | 直接改库结构,不产生文件 | 原型阶段、临时环境 |
drizzle-kit studio | 启动可视化数据浏览器 | 调试 |
pnpm drizzle-kit generate
pnpm drizzle-kit migrate
generate 产出的 SQL 是可以逐行 review 的,这是 Drizzle 相比 db push 的工程优势:迁移是一等公民,进版本库、进 CI。生产环境的迁移流水线、灰度与回滚策略放在 7.3 迁移、事务与连接池
中展开。
注意 Drizzle 的迁移快照存在 drizzle/meta/ 下。这个目录必须提交,否则下次 generate 无法判断差异,会试图重复创建已有的表。同时要确保 CI 中的迁移步骤只运行 migrate,不要顺手跑 generate,否则会生成无意义的空迁移文件。
事务与预编译语句
事务用法与驱动层的 BEGIN/COMMIT 语义一致:
await db.transaction(async (tx) => {
const [order] = await tx.insert(orders).values({ userId, amount: '99.00' }).returning()
await tx.insert(orderItems).values({ orderId: order.id, sku: 'A-1', qty: 2 })
})
tx 的类型与 db 几乎一致,所以仓储函数可以写成「接受一个执行器」的形式,在事务内外都能复用:
type Executor = typeof db | Parameters<Parameters<typeof db.transaction>[0]>[0]
export function createUserRepo(exec: Executor) {
return {
findById: (id: string) => exec.select().from(users).where(eq(users.id, id)).limit(1),
}
}
高频查询可以预编译,把 SQL 的解析与计划缓存起来:
const userByEmail = db
.select({ id: users.id, email: users.email })
.from(users)
.where(eq(users.email, sql.placeholder('email')))
.prepare('user_by_email')
const row = await userByEmail.execute({ email: 'a@b.com' })
sql.placeholder('email') 与 .prepare() 的配合是 Drizzle 独有的便利:占位符的类型会被推断,execute 的参数也就跟着类型安全。
常见坑与报错对照
| 现象 | 原因 | 处理 |
|---|---|---|
db.query.users 是 undefined | drizzle() 没传 { schema } | 补上 drizzle(client, { schema }) |
Expected an array | 索引回调返回了对象 | 改成返回数组 |
Block-scoped variable used before its declaration | references 没写成箭头函数 | 改为 () => users.id |
金额算出来是 "99.00" 字符串 | numeric 默认返回 string | 用 decimal 库或显式转换 |
| 迁移重复建表 | drizzle/meta/ 没提交 | 把它加入版本控制 |
类型是 string 运行时却是 null | sql<T> 泛型写错 | 用 ::int 等 SQL 转换对齐 |
还有一个概念性坑:Drizzle 的「不需要 codegen」不代表「类型永远正确」。sql<T> 模板、db.execute() 的返回值都是类型断言的产物,编译器无法验证。真正的防线是集成测试——在真实数据库上跑一遍,用 Testcontainers 起一个临时 PostgreSQL 实例,验证查询结果与类型一致。做法见 4.2 集成测试与 Testcontainers
。数据访问层的测试要点还可以参考 数据库 Schema 测试
。
什么时候该选 Drizzle
结合前面的细节,可以给出几条偏经验的判断:
- 选 Drizzle:查询复杂(多表 join、窗口函数、CTE)、团队熟悉 SQL、希望迁移 SQL 可 review、不想引入 codegen 步骤。
- 选 Prisma:业务以 CRUD 为主、团队 SQL 经验不均、希望业务代码零 SQL、需要
GetPayload这类从查询形状提取类型的便利。 - 混合:读路径用 Drizzle 写复杂报表,写路径用 Prisma 保证一致性——可行,但两套 schema 定义要人工同步,除非用
prisma db pull之类的桥接手段,成本不低。
无论选哪个,数据访问层的边界应当收在仓储函数里:业务层只看到领域类型,不看到 db。这样将来换 ORM 的成本才可控。类型测试与覆盖率门禁见 4.3 类型测试与覆盖率门禁
。
下一节我们把两条路线共有的难题补齐:schema 变更怎么迁移、事务怎么写才不留半成品、连接池该配多大。
小结
这一节我们从「反方向」看了一遍类型安全的数据访问:
- Drizzle 的表结构写在 TypeScript 文件里,没有 codegen 步骤,TS 编译器直接读 schema 推导类型。
.notNull()决定T还是T | null,numeric默认返回string,枚举推导为字面量联合——这三条是读 schema 推类型的全部规则。$inferSelect与$inferInsert的差别在可选性,前者是读出来的行,后者是准备写进去的行。select的投影会收窄返回类型,innerJoin保持可空性而leftJoin让右侧全部可空。- 关系查询 API 把嵌套结构还原成一条 SQL,比手写循环安全;
sql<T>是唯一的类型断点,必须靠集成测试兜底。
至此两条路线都见过了。但它们解决的是「怎么读怎么写」,还没解决「结构怎么变、并发怎么写、连接从哪来」——这是下一节的主题。
阅读导航:上一节:7.1 Prisma schema 与类型生成 · 下一节:7.3 迁移、事务与连接池 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。