《TypeScript编程实战》7.2 Drizzle 的 SQL 式类型推导

本节换一条与 Prisma 相反的路线:用 TypeScript 文件本身描述表结构,让类型从查询语句推导出来。先安装 drizzle-orm 与 drizzle-kit,用 pgTable 定义表、列、枚举与索引,再讲清 $inferSelect 与 $inferInsert 的差别,随后逐个演示 select、join、where 与关系查询 API 的类型结果,最后给出迁移命令与选型建议。

本节目标:理解 Drizzle 与 Prisma 的路线差异——它不做代码生成,表结构写在 TypeScript 文件里,类型随查询语句自然推导。读完后你能用 pgTable 定义一套可迁移的表结构,说清 $inferSelect 与 $inferInsert 为什么不同,并能在 select、join、关系查询三种写法之间做出正确选择。

7.2 Drizzle 的 SQL 式类型推导

上一节的 Prisma 走的是「先描述、后生成」:schema.prisma 是一份独立 DSL,prisma generate 把它编译成 TypeScript 类型。Drizzle 的思路完全相反——表结构就是普通的 TypeScript 代码,类型由查询本身推导出来。没有 codegen 步骤,也没有第二份需要同步的文件。

两条路线的正面对照

先建立整体印象,再展开细节:

维度PrismaDrizzle
表结构载体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),
  ],
)

这里有三个关键约定:

  1. 列名与字段名分离。email: text('email') 里,键是 TypeScript 属性名,字符串参数是数据库列名。要写成 snake_case 列名只需 createdAt: timestamp('created_at'),TS 侧依然是 camelCase。
  2. .notNull() 直接决定类型。写了就是 string,不写就是 string | null。这个映射没有任何隐式规则,读 schema 就能推出类型。
  3. 索引放在第三个参数的数组里。旧版本要求返回对象,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 是 undefineddrizzle() 没传 { schema }补上 drizzle(client, { schema })
Expected an array索引回调返回了对象改成返回数组
Block-scoped variable used before its declarationreferences 没写成箭头函数改为 () => users.id
金额算出来是 "99.00" 字符串numeric 默认返回 string用 decimal 库或显式转换
迁移重复建表drizzle/meta/ 没提交把它加入版本控制
类型是 string 运行时却是 nullsql<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 迁移、事务与连接池 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「typescript」更多文章

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