Prisma作为目前Node.js生态中炙手可热的ORM,其类型推导能力一直是它的招牌。不过一旦涉及事务和中间件,不少项目的封装代码就会退化成any满天飞的状态:事务回调里拿到tx对象不知道是什么类型,中间件里解析params全靠猜,改一个字段类型检查全线报错。这篇文章就来解决这些问题,用TypeScript的泛型和高级类型技巧,给Prisma的事务处理与中间件做一套真正类型安全的封装。

理解Prisma事务API的类型结构
Prisma提供两种事务方式。第一种是顺序事务,通过$transaction传入一个操作数组,所有操作要么全部成功要么全部回滚,适合一批相互独立写操作的场景。第二种是交互式事务,传入一个异步回调函数,回调参数是一个Prisma.TransactionClient对象,可以在事务内部先查询再根据结果决定后续写入,灵活性高得多。
麻烦主要出在交互式事务上。很多团队为了复用事务逻辑,会写一个工具函数把$transaction包一层,但如果参数类型写得不严谨,回调里的tx就会变成any或者过宽的类型。正确的做法是直接依赖Prisma生成的类型,让回调签名原样透传。看下面这个封装:
import { PrismaClient, Prisma } from '@prisma/client';
const prisma = new PrismaClient();
// 事务回调的标准签名:接收TransactionClient,返回泛型R
type TransactionCallback<R> = (
tx: Prisma.TransactionClient
) => Promise<R>;
async function useTransaction<R>(
fn: TransactionCallback<R>,
options?: Partial<Prisma.TransactionOptions>
): Promise<R> {
return prisma.$transaction(fn, {
maxWait: 5000,
timeout: 10000,
...options,
});
}这个写法的关键点在于TransactionCallback<R>这个泛型别名。返回值类型R由编译器根据传入的回调自动推导,所以无论事务里返回的是一个user对象还是void,类型都能精确保留。回调参数tx显式标注为Prisma.TransactionClient,这个类型是PrismaClient的子集,只包含数据操作方法,不含$connect、$disconnect这类连接管理方法,语义上正好符合事务内部的合法操作范围。
另外一个容易踩的坑是嵌套事务。Prisma不支持在事务回调里再调用$transaction,如果你写的业务函数既可能被单独调用,又可能在事务内复用,建议统一让函数接收tx参数。可以定义一个组合类型来兼容两种调用场景:
type DbClient = Prisma.TransactionClient | PrismaClient;
async function transferPoints(
db: DbClient,
fromId: number,
toId: number,
amount: number
) {
await db.pointRecord.update({
where: { id: fromId },
data: { balance: { decrement: amount } },
});
await db.pointRecord.update({
where: { id: toId },
data: { balance: { increment: amount } },
});
}
// 单独调用时包一层事务
async function transferPointsWithTx(fromId: number, toId: number, amount: number) {
return useTransaction((tx) => transferPoints(tx, fromId, toId, amount));
}由于TransactionClient是PrismaClient的结构子类型,这个联合类型参数在两种场景下都能通过类型检查,而且db.pointRecord.update的参数依然享有完整的类型提示和校验,不会丢失字段名拼写错误这类低级问题的静态检查能力。
给Prisma中间件加上类型约束
Prisma中间件通过prisma.$use注册,每个查询都会流经它。中间件签名本身是有类型的,接收Prisma.MiddlewareParams和next函数,但很多教程示例里直接对params下手时用了类型断言,导致后续维护毫无安全性。我们先把中间件的类型基础打牢:
prisma.$use(async (params, next) => {
const start = Date.now();
const result = await next(params);
const duration = Date.now() - start;
console.log(`查询 ${params.model}.${params.action} 耗时 ${duration}ms`);
return result;
});params.model、params.action以及params.args都是官方类型定义好的字段,其中model和action是字符串字面量联合类型,可以直接做条件判断。比如要实现软删除,只在查询user模型时自动注入删除标记过滤,可以这样写:
prisma.$use(async (params, next) => {
if (params.model === 'User' && ['findMany', 'findFirst', 'findUnique', 'count'].includes(params.action)) {
// args可能为undefined,需要兜底
params.args = {
...params.args,
where: {
...params.args?.where,
deletedAt: null,
},
};
}
return next(params);
});如果想更进一步,根据模型过滤中间件行为,可以用类型映射来维护一个模型配置表,让编译器保证配置的key都是合法模型名:
import { Prisma } from '@prisma/client';
// 只允许合法的模型名作为key
type SoftDeleteConfig = Partial<Record<Prisma.ModelName, boolean>>;
const softDeleteModels: SoftDeleteConfig = {
User: true,
Post: true,
Comment: false,
};
prisma.$use(async (params, next) => {
if (params.model && softDeleteModels[params.model]) {
// 对启用了软删除的模型注入过滤条件
if (params.args?.where && 'deletedAt' in params.args.where) {
// 已显式指定时不覆盖
return next(params);
}
params.args = {
...params.args,
where: { ...params.args?.where, deletedAt: null },
};
}
return next(params);
});这里用到的Prisma.ModelName是Prisma自动生成的模型名联合类型,配置表里写错模型名会直接编译报错,比手写字符串数组可靠得多。审计日志中间件也是类似的思路,判断params.action是否属于create、update、delete这几个写操作,然后把params连同调用上下文一起记录下来。
需要注意的是,中间件官方已经标记为推荐用Client Extensions替代的方向。中间件的优势在于全局生效、写法直观;而扩展(prisma.$extends)能把自定义方法挂到模型上,并且结果类型会被精确推导,比如给查询结果附加计算字段时,扩展能拿到正确的返回类型而中间件只能返回any。实际项目里,全局横切逻辑(日志、软删除)用中间件或扩展的query钩子都可以,模型级自定义方法则优先选扩展。
在业务层落地:类型安全的事务模板
有了前面的基础,可以把事务、重试、错误转换整合成一个生产可用的模板。交互式事务默认超时只有5秒,遇到死锁或连接抖动时会直接抛错,对关键写操作加上有限次重试能显著提升稳定性。同时把Prisma的PrismaClientKnownRequestError翻译成业务异常,避免数据库层错误码泄漏到接口层:
import { Prisma, PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const RETRYABLE_CODES = ['P2034']; // P2034为事务冲突
export async function runInTx<R>(
fn: (tx: Prisma.TransactionClient) => Promise<R>,
retries = 3
): Promise<R> {
for (let attempt = 0; attempt <= retries; attempt++) {
try {
return await prisma.$transaction(fn, { timeout: 15000 });
} catch (error) {
const known = error instanceof Prisma.PrismaClientKnownRequestError;
if (known && RETRYABLE_CODES.includes(error.code) && attempt < retries) {
// 指数退避后重试
await new Promise((r) => setTimeout(r, 100 * 2 ** attempt));
continue;
}
if (known && error.code === 'P2002') {
throw new Error('唯一约束冲突,请检查提交的数据');
}
throw error;
}
}
throw new Error('unreachable');
}在NestJS这类框架里集成时,还有两点值得注意。第一,PrismaClient建议通过依赖注入提供单例,事务工具函数不要自己new客户端,而是把$transaction的调用封装到服务内部,保持客户端生命周期由框架统一管理。第二,如果用了Prisma Extension,注意prisma.$extends返回的是新客户端实例,类型是PrismaClient<...>的扩展形态,事务回调拿到的tx并不自动带上扩展方法,需要通过扩展的client或query组件配合,或者把扩展逻辑设计成不依赖具体实例的纯函数再在事务内手动调用。
最后总结一下封装思路:事务层面,用泛型回调别名让TransactionClient在工具函数中原样流动,配合联合类型参数实现事务内外两用的业务函数;中间件层面,依靠Prisma.MiddlewareParams的字面量联合类型和Prisma.ModelName映射配置,把运行时检查变成编译期检查。这样封装出来的数据访问层,重构schema后类型错误会立刻暴露,代码提示也一路畅通,长期维护成本会低很多。
TypeScriptPrisma ORM事务处理修改时间:2026-09-13 10:54:40