在基于 AWS Amplify Data 的项目里,实时订阅通常用来接收数据创建、更新和删除事件。订阅请求中的 filter 参数能帮助客户端只接收自己关心的数据,但很多项目会把它声明成 any、Record<string, any> 或者直接写成普通对象字面量。这样做虽然写起来快,代价却是失去 TypeScript 的编译期保护——字段名拼错、过滤值类型写反、嵌套关系层级不匹配这些问题都只能等到运行时才被发现。本文会把这层过滤条件抽成可复用的类型,让 Amplify Data 的实时订阅调用像操作强类型模型一样安全。

先看实时订阅过滤条件的实际形态
Amplify Data 的 GraphQL 订阅一般通过生成客户端发起。下面是一个常见写法,订阅 Todo 模型创建事件,并用 filter 限定标题包含 report、completed 为 false、priority 大于等于 3 的记录。
import { generateClient } from 'aws-amplify/data';
import type { Schema } from '../amplify/data/resource';
const client = generateClient<Schema>();
const subscription = client.graphql({
query: `
subscription OnCreateTodo($filter: SubscriptionFilter) {
onCreateTodo(filter: $filter) {
id
title
completed
priority
}
}
`,
variables: {
filter: {
title: { contains: 'report' },
completed: { eq: false },
priority: { gte: 3 },
},
},
});
这段代码能正常运行,但 filter 没有被强约束。GraphQL 查询里的 SubscriptionFilter 是后端的标量类型,TypeScript 在 variables 这一层通常只能推断成 any 或一个非常宽泛的对象。也就是说,如果把 title 写成 tittle,把 contains 写成 contain,编辑器不会有任何提示。团队里一旦有人误删字段或改了模型,这类问题会积累在运行日志里。
更麻烦的是嵌套关系。比如 Todo 下面还有 Project,想在订阅时按 Project 名称过滤,过滤对象层级会更深。没有类型约束时,开发者只能靠文档或记忆去拼结构,每次修改都像在盲写。因此,我们需要根据数据模型生成一套字段级别的过滤类型。
用 keyof 和条件类型搭出标量过滤
核心思想是利用 keyof 提取模型字段,再根据字段类型映射到对应过滤操作。比如 string 字段支持 contains、beginsWith、notContains,number 字段支持 gte、lte、gt、lt,boolean 字段只支持 eq 和 ne。先用基础类型定义这些操作。
type StringFilter = {
eq?: string;
ne?: string;
contains?: string;
notContains?: string;
beginsWith?: string;
};
type NumberFilter = {
eq?: number;
ne?: number;
le?: number;
lt?: number;
ge?: number;
gt?: number;
};
type BooleanFilter = {
eq?: boolean;
ne?: boolean;
};
type ScalarFilter = StringFilter & NumberFilter & BooleanFilter;
type Todo = {
id: string;
title: string;
completed: boolean;
priority: number;
};
type FilterForScalar<T> = T extends string
? StringFilter
: T extends number
? NumberFilter
: T extends boolean
? BooleanFilter
: never;
type TodoFilter = {
[K in keyof Todo]?: FilterForScalar<Todo[K]>;
};
这里的 FilterForScalar<T> 是一个条件类型,它会根据传入的字段类型自动分发到对应的过滤接口。比如 title 是 string,就会得到 StringFilter,于是 title: { contains: 'report' } 合法,而 title: { gte: 3 } 会直接报错。映射类型 [K in keyof Todo]? 让所有字段都变成可选属性,因此过滤对象里可以只写需要过滤的字段,不必把模型的每个属性都列出来。
不过这套类型只覆盖了标量字段。一旦 Todo 模型中存在关系字段,比如 project 指向 Project 对象,上面的条件类型会落到 never 分支,导致无法在过滤条件里书写任何关系过滤。真实业务里关系过滤非常常见,所以需要继续扩展递归结构。
组合条件与嵌套关系过滤
真实 GraphQL 订阅过滤器通常支持 and、or、not 组合多个条件,同时模型之间还有关联字段。比如 Todo 属于某个 Project,过滤条件可能需要写成 project: { name: { beginsWith: 'core' } }。所以要把过滤器扩展为递归结构,让每个层级都拥有标量过滤和组合能力。
type FilterInput<T> = {
[K in keyof T]?: T[K] extends string
? StringFilter
: T[K] extends number
? NumberFilter
: T[K] extends boolean
? BooleanFilter
: T[K] extends object | null | undefined
? FilterInput<T[K]>
: never;
} & {
and?: FilterInput<T>[];
or?: FilterInput<T>[];
not?: FilterInput<T>;
};
这个定义在遇到 object 字段时会递归进入 FilterInput<T[K]>,所以 Project 内部的 name 字段又能继续使用 StringFilter。and、or、not 被放进交叉类型里,让它们出现在任何过滤层级中。这样的写法可以表达 title: { contains: 'report' }, project: { name: { beginsWith: 'core' } } 这种嵌套结构,也可以表达 or: [{ completed: { eq: false } }, { priority: { ge: 3 } }] 这样的组合条件。
需要说明的是,这个结构不是 Amplify 官方生成的类型,而是我们在封装层建立的一套约定。不同后端或不同 Amplify Data 版本支持的过滤操作可能有差异,例如某些关系字段不允许 not 组合,或者字符串字段不支持 notContains。因此落地时还要结合后端实际能力做裁剪,但这套类型已经把最常用的过滤形态约束起来了。
封装订阅函数并在组件里安全调用
有了 FilterInput<T>,下一步就是把它接到订阅函数的签名上。可以写一个 subscribeTodos 函数,入参明确要求 FilterInput<TodoWithProject>,返回订阅句柄。这样业务组件不需要接触底层 client.graphql 的 any,只需关心过滤条件本身。
type Project = {
id: string;
name: string;
};
type TodoWithProject = Todo & {
project?: Project;
};
function subscribeTodos(
filter: FilterInput<TodoWithProject>,
onNext: (todo: TodoWithProject) => void
) {
const client = generateClient<Schema>();
return client.graphql({
query: `
subscription OnCreateTodo($filter: SubscriptionFilter) {
onCreateTodo(filter: $filter) {
id
title
completed
priority
project {
id
name
}
}
}
`,
variables: { filter },
}).subscribe({
next: (event) => onNext(event.data.onCreateTodo),
});
}
组件里调用时,过滤对象会获得完整类型提示。下面是一次合法调用,标题用 contains、completed 用 eq、priority 用 ge,同时嵌套过滤 Project 名称。
const subscription = subscribeTodos(
{
title: { contains: 'report' },
completed: { eq: false },
priority: { ge: 3 },
project: {
name: { beginsWith: 'core' },
},
},
(todo) => {
console.log('received', todo.title);
}
);
subscription.unsubscribe();
如果把过滤值写错类型,TypeScript 会立刻在编辑器中标红。例如 contains 传数字、eq 传字符串、gte 传字符串,都会在编译阶段被拦截。这比等到订阅发布后才发现要安全得多,尤其适合多人协作、模型频繁调整的项目。
subscribeTodos(
{
title: { contains: 123 }, // contains 期望字符串
completed: { eq: 'false' }, // eq 期望布尔值
priority: { gte: 'high' }, // gte 期望数值
},
(todo) => console.log(todo)
);
与 Amplify 生成类型集成的注意点
理想情况下,我们希望过滤类型完全从 Amplify 的 schema 自动生成。但 Amplify Data 生成的客户端类型里,订阅 filter 变量有时仍然是宽泛类型,或者关系过滤的建模方式与自定义类型不一致。实际封装时可以在 generateClient 的 Schema 类型基础上再包一层,而不是从零手写模型类型。
例如可以从 Schema['Todo']['type'] 提取字段类型,再让 FilterInput<TodoModel> 去推导。但这里要注意 Amplify 的字段可能带有 nullable 标记,类型往往是 string | null,条件类型分发需要妥善处理,否则可能落到 never。可以增加一个 NonNullable 归一化步骤,或者为 null、undefined 单独设置分支,确保关系字段不会被忽略。
维护层面,建议把过滤类型放到单独的 filters.ts 或 subscriptions.ts,和模型定义放一起。当 schema 增加字段时,如果字段类型在已有映射中,过滤能力会自动获得;如果新增关系或枚举,更新条件类型分支即可。配合少量单元测试验证后端接受的 filter 是否符合前端约束,可以让这套封装在长期项目中保持可靠。

