广告位 · 页头(后台-模块管理-新建模块后替换此区域)

如何用TypeScript为Amplify Data实时订阅封装类型安全的过滤条件?

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

如何用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 是否符合前端约束,可以让这套封装在长期项目中保持可靠。

广告位 · 文章底部(后台-模块管理替换此区域)