Prisma Pulse 订阅接口得到的数据库变更事件,在 TypeScript 中往往是一个庞大的联合类型。比如 User 表的建表事件与 Post 表的删除事件会被塞进同一个回调参数里,如果想在回调中精确访问 event.after.name,类型检查器很可能给出属性不存在或可能为空的警告。原因不是 Prisma 类型不全,而是没有把订阅场景进一步收窄。本文就聚焦如何用类型系统为 Prisma Pulse 封装一层过滤类型,让编译期就能精确识别不同表的变更。

先理解 Prisma Pulse 事件对象的形状
Prisma Pulse 每次数据库变更都会生成一个事件,事件里至少包含操作类型、创建时间和变更前后的数据快照。以一条更新用户姓名的操作为例,事件大致由 action、created、before 和 after 组成,其中 before 保存更新前的记录,after 保存更新后的记录。创建操作没有 before,删除操作没有 after,这两类事件的对应字段通常是 null。
在 Prisma Client 生成的类型中,订阅回调里的 event 往往被推断为 CreateEvent、UpdateEvent 和 DeleteEvent 的联合,而这些事件又按模型交叉展开。也就是说,同一回调里既可能出现 User 表数据,也可能出现 Post 表数据。如果不加约束,想要安全读取某个模型字段,就得手动做大量 if 判断或者强制类型断言。
要解决这个问题,第一步是建立表名到 Prisma 模型类型的映射。代码里可以用一个接口把监听的表全部列出来。下面以 User 和 Post 两张表为例:
type TableMap = {
User: { id: number; name: string };
Post: { id: number; title: string; authorId: number };
};
type TableName = keyof TableMap;
type Op = 'create' | 'update' | 'delete';
这里的 TableMap 并不需要完全复制 Prisma 生成类型,可以从 Prisma.UserGetPayload<{}> 等类型中引用,但为了演示方便,先用手写结构。接下来所有过滤类型都会基于这张映射展开。
用条件类型和映射类型构造过滤器
有了 TableName 和 Op,下一步就是把这些基础类型组合成可复用的过滤条件。一个过滤条件通常包含表名、操作类型和可选的字段白名单。这里的核心不是运行时逻辑,而是让 TypeScript 根据传入的过滤条件推导出回调事件的具体类型。
先定义事件类型。事件需要根据操作类型决定 before 和 after 是否为 null。可以利用条件类型:如果是创建操作,before 为 null;如果是删除操作,after 为 null;更新操作则两者都存在。代码实现如下:
type PulseEvent<TName extends TableName, TOp extends Op> = {
action: TOp;
created: Date;
before: TOp extends 'create' ? null : TableMap[TName];
after: TOp extends 'delete' ? null : TableMap[TName];
};
type Filter<TName extends TableName = TableName> = {
table: TName;
op?: Op;
changedFields?: Array<keyof TableMap[TName]>;
};
这里的 PulseEvent 用泛型参数 TName 指定表名,用 TOp 指定操作类型。条件类型的作用是:当 TOp 为 create 时,before 被推导为 null,而 after 为对应模型类型;当 TOp 为 delete 时,after 为 null。这种类型定义能让后续订阅函数具备很强的自动推导能力。
Filter 类型还额外加了 changedFields,它接受模型字段名数组。比如监听 User 表时,这个数组只能填写 id 或 name,写 title 会直接报错。这样就把字段名也纳入了编译期约束。
封装订阅函数让调用处自动推断
过滤类型只是基础,真正方便的是把 pulse.subscribe 包一层,使过滤条件与回调事件类型联动。假设有一个 pulse 实例,可以写一个 subscribeToChanges 函数,接收一个过滤条件和回调处理器。函数签名设计成泛型,让 TypeScript 从第一个参数推导出 TName 和 TOp。
function subscribeToChanges<TName extends TableName, TOp extends Op = Op>(
filter: Filter<TName> & { op?: TOp },
handler: (event: PulseEvent<TName, TOp>) => void
) {
// 实际项目中调用 pulse.subscribe,并按 filter.table 和 filter.op 做路由
return {
name: 'filtered-change-stream',
onEvent: handler
};
}
这个函数里第二个参数 handler 的 event 类型由第一个参数决定。如果调用时写 table: 'User',编译器就会把 event.after 推断为 User 模型类型;如果同时写 op: 'update',编译器还会进一步把 before 和 after 都确定为非空,省去大量空值判断。
下面是一个完整的调用示例:
subscribeToChanges({ table: 'User', op: 'update' }, (event) => {
console.log(event.before.name); // 修改前的姓名
console.log(event.after.name); // 修改后的姓名
});
subscribeToChanges({ table: 'Post', op: 'create' }, (event) => {
console.log(event.before); // null
console.log(event.after.title);
});
如果在 User 更新回调里尝试访问 event.after.title,类型检查器会立即提示 User 上没有 title 属性;如果访问创建事件的 before 还会提示可能为 null。这些错误在开发阶段就能暴露,不需要等到运行时才发现。
字段级变更检查的边界与运行时辅助
类型系统能保证字段名合法,但无法自动判断数据库里某个字段是否真的发生了变化。例如一个更新事件即使没有传 changedFields,开发者往往希望在回调中只处理 name 变化的记录。这种需求需要运行时比较 before 和 after,不过类型封装仍然可以提供辅助函数来保证比较逻辑安全。
可以写一个 hasChanged 函数,接收事件和字段名,内部比较 before 与 after 的值。函数签名依然基于 PulseEvent 和 keyof TableMap[TName],这样传入的字段名必须是合法字段。示例:
function hasChanged<TName extends TableName, TOp extends Op>(
event: PulseEvent<TName, TOp>,
field: keyof TableMap[TName]
): boolean {
if (event.before === null || event.after === null) {
return event.before === null && event.after !== null;
}
return event.before[field] !== event.after[field];
}
这段代码里 before 和 after 的类型会根据 TOp 收窄:创建事件时 before 为 null,删除事件时 after 为 null。不过由于 TypeScript 的控制流分析还不够跨函数保留泛型条件,函数内部仍然需要一次 null 检查,否则直接访问 event.before[field] 可能报错。这也是类型封装无法替代运行时校验的地方,但至少字段名和调用方式已经足够安全。
另外,在真实项目里,Prisma Pulse 的事件类型可能由 Prisma Client 自动生成,并不一定和手写的 TableMap 完全一致。建议把 TableMap 映射到实际模型导入类型,例如从 Prisma.ModelNameGetPayload 提取,或者直接使用 Prisma.TableName 作为映射值。这样后续模型字段变更时,过滤类型也能跟着自动更新。
Prisma PulseTypeScript变更数据捕获修改时间:2026-09-19 05:48:58