TanStack Query Angular 的 queryOptions:以类型安全的方式共享与复用查询配置
本文面向在 Angular 项目中使用 @tanstack/angular-query-experimental 的开发者,围绕 queryOptions 官方参考文档 展开,结合其仓库实现(query-options.ts)讲解这一类型辅助函数的用途、三组重载签名、类型标注机制与实战用法。读完本文你将理解 queryOptions 为什么是「零运行时成本」的纯类型工具,掌握如何用它把 queryKey 与 queryFn 的数据类型绑定在一起,让 injectQuery、getQueryData 等 API 获得精确的类型推断。
一、queryOptions 是什么,解决了什么问题
queryOptions 是 @tanstack/angular-query-experimental 导出的一个类型辅助函数(官方文档原话:Allows sharing and re-using query options in a type-safe way),核心能力可概括为两句话:
- 允许把一份 query 配置(queryKey、queryFn、以及各类选项)抽出来,在多个地方安全地共享与复用;
- 它会用
queryFn的返回类型去「标记(tag)」配置中的queryKey,让queryKey本身携带数据类型的元信息。
这一机制解决的是 Angular 应用中很常见的一类痛点:当配置被抽离到 Service 或常量文件后,queryKey 和 queryFn 往往失去绑定关系,导致在调用 queryClient.getQueryData(queryKey)、injectQuery 等接口时,TypeScript 无法再推导出查询数据的精确类型,只能退回到 unknown 或手动 as 断言。
官方文档给出的最小示例即直观展示了这一能力:
const { queryKey } = queryOptions({
queryKey: ['key'],
queryFn: () => Promise.resolve(5),
// ^? Promise<number>
})
const queryClient = new QueryClient()
const data = queryClient.getQueryData(queryKey)
// ^? number | undefined
可以看到:queryKey 从 queryOptions 取出后,getQueryData 无需额外传泛型就能推断出数据为 number | undefined,而不是笼统的 unknown。
二、核心机制:为什么 queryKey 会「记得」数据的类型
queryOptions 本身不是运行时逻辑。翻看它的实现(query-options.ts:169),函数体只有一行:
export function queryOptions(options: unknown) {
return options
}
也就是说它在运行时原封不动地返回传入对象。其类型标记能力完全建立在 TypeScript 类型层之上,依据如下(均为仓库内可查证的源码事实):
- 在 query-options.ts:61-63,query-core 导入了
dataTagSymbol、dataTagErrorSymbol两个用于标记的 symbol; - query-core/src/types.ts:71-80 定义了
DataTag<TType, TValue, TError>,通过TType & { [dataTagSymbol]: TValue; [dataTagErrorSymbol]: TError }把值与错误类型「打」在类型上; - query-core/src/types.ts:82-88 的
QueryKeyWithDataTag将标记作用到queryKey字段:{ queryKey: DataTag<TQueryKey, TQueryFnData, TError> }; - 反过来,query-core/src/types.ts:90-100 的
InferDataFromTag/InferErrorFromTag会从已标记的 queryKey 中反解出数据与错误类型,供getQueryData等 API 使用。
因此可以这样理解「标记」的完整链路:queryOptions 返回类型里将 queryKey 与被 queryFn 推导出的 TQueryFnData/TError 绑定;当 queryKey 被传入 getQueryData/injectQuery 等消费端时,消费端类型通过 DataTag 反解出绑定类型。两个 symbol 都是普通的全局唯一 Symbol 常量,并不参与真实对象键值,所以不会在运行时污染 queryKey。
三、调用签名与三组重载的取舍
与 React Query 等适配层不同,Angular 版 queryOptions 在 query-options.ts 中声明了三个重载(Call Signature),分别对应三类不同的 initialData / queryFn 形态:
签名一:DefinedInitialDataOptions(initialData 必填且非 undefined)
function queryOptions<TQueryFnData, TError, TData, TQueryKey>(
options: DefinedInitialDataOptions<TQueryFnData, TError, TData, TQueryKey>,
): DefinedInitialDataOptions<TQueryFnData, TError, TData, TQueryKey> &
QueryKeyWithDataTag<TQueryKey, TQueryFnData, TError>
签名二:UnusedSkipTokenOptions(queryFn 排除了 skipToken)
query-options.ts:107-115:该分支移除 queryFn 中使用 skipToken 的可能,用于配置明确会发起请求、queryKey 必须「有用」的场景。
签名三:UndefinedInitialDataOptions(通用形态,initialData 允许为 undefined)
query-options.ts:138-146:普通、无特殊约束的 query 配置,数据可能为 undefined。
三个分支的类型实体同样定义在本文件中:
UndefinedInitialDataOptions(query-options.ts:13-23):initialData可缺省,也可以是InitialDataFunction<NonUndefinedGuard<TQueryFnData>>或非 undefined 的值;UnusedSkipTokenOptions(query-options.ts:25-38):通过OmitKeyof<CreateQueryOptions, 'queryFn'>去掉原 queryFn 类型,再收窄为排除了SkipToken | undefined的版本;DefinedInitialDataOptions(query-options.ts:40-53):initialData为必填,其取值必须是NonUndefinedGuard<TQueryFnData>(保证类型不为 undefined),使查询结果可被推断为「已定义」。
泛型参数的默认值
四个泛型参数在三个签名中保持一致(query-options.ts:76-80):
| 泛型 | 默认值 | 含义 |
|---|---|---|
TQueryFnData |
unknown |
queryFn 返回的原始数据 |
TError |
DefaultError |
默认错误类型,未自定义 Register 时为 Error(见 query-core/src/types.ts:45-49) |
TData |
TQueryFnData |
经过 select 等变换后的最终数据 |
TQueryKey |
readonly unknown[] |
要求 queryKey 是只读的 unknown 数组(extends readonly unknown[]) |
底层 options 类型来自 CreateQueryOptions,它继承自 query-core 的 QueryObserverOptions,并剔除了 'suspense' 字段,覆盖 queryKey、queryFn、staleTime、gcTime、refetchInterval、enabled、retry、placeholderData 等常规选项,具体字段与默认值可进一步查阅 query-core 源码中的类型定义。
四、返回值:被标记的 queryKey
无论走哪个重载,返回值都满足同一条规律——在传入 options 的基础上叠加 QueryKeyWithDataTag:
- 签名一返回
DefinedInitialDataOptions & QueryKeyWithDataTag; - 签名二返回
UnusedSkipTokenOptions & QueryKeyWithDataTag; - 签名三返回
UndefinedInitialDataOptions & QueryKeyWithDataTag。
因此返回对象里能被立即安全解构的核心字段是 queryKey(解构出的 queryKey 已带类型标签)。对返回结果的其他字段(如 queryFn、staleTime 等)原样保留,可继续传给 injectQuery 或 injectQueries 消费。
五、运行时行为:零开销的恒等函数
尽管文档与类型层面讨论繁多,运行时行为却极为朴素。仓库测试文件 query-options.test.ts:5-13 中明确定义并验证了这一契约:
describe('queryOptions', () => {
it('should return the object received as a parameter without any modification.', () => {
const object: CreateQueryOptions = {
queryKey: ['key'],
queryFn: () => Promise.resolve(5),
} as const
expect(queryOptions(object)).toBe(object)
})
})
测试名称即契约:queryOptions 原封不动返回传入对象(恒等),不产生任何包装或副本。这意味着:
- 没有额外内存/对象创建开销,可放心用于「每次渲染重建 options」的写法;
- 它纯靠类型标注实现能力,同一测试目录下还有配套的类型级断言(.test-d.ts 测试族),从编译期锁定推断结果;
- 与
injectQuery这类依赖 Angular 注入的运行时代理不同,queryOptions 是一个无副作用的纯类型辅助函数,可在模块顶层、Service、class 属性等任意位置安全定义。
从源码结构看,这也解释了它为什么在包导出中被独立列出:index.ts:8-13 将 queryOptions 连同 DefinedInitialDataOptions、UndefinedInitialDataOptions、UnusedSkipTokenOptions 三个类型一并对外导出,作为「预定义 options」基础设施的一部分。同族的兄弟函数还有面向数据变更的 mutationOptions 与面向无限查询的 infiniteQueryOptions,二者在官方参考文档中均有对应条目(mutationOptions、infiniteQueryOptions)。
六、典型实战用法
1. 在 Service 中集中定义并共享 options
将 queryOptions 与「从 Service 提取 options」的模式结合(对应仓库示例目录 examples/angular/query-options-from-a-service):
// todos.options.ts
import { queryOptions } from '@tanstack/angular-query-experimental'
export const todoOptions = {
list: () =>
queryOptions({
queryKey: ['todos'],
queryFn: () => fetchTodos(), // 假定返回 Promise<Todo[]>
}),
detail: (id: number) =>
queryOptions({
queryKey: ['todos', id],
queryFn: () => fetchTodo(id),
}),
}
2. 共享给 injectQuery / getQueryData 消费
配置在组件里被 injectQuery 消费,同时可被其它位置用同一份 queryKey 读取缓存:
import { Component, inject } from '@angular/core'
import { injectQuery, injectQueryClient } from '@tanstack/angular-query-experimental'
import { todoOptions } from './todos.options'
@Component({ ... })
export class TodosComponent {
private queryClient = injectQueryClient()
readonly todosQuery = injectQuery(() => todoOptions.list())
// 无需重复写泛型,queryKey 携带了数据与错误类型,
// 非响应式读取路径同样能获得精确推断
private debugRead() {
const data = this.queryClient.getQueryData(todoOptions.list().queryKey)
// ^? Todo[] | undefined
}
}
这里的要点是:injectQuery 要求传工厂函数(每次调用重建 options 以保持响应性),而 queryOptions 的恒等特性恰好保证了工厂每次返回的对象即我们集中定义的同一份配置,类型与运行时都能保持同步。
3. 与 as const / 数组 key 结合
TQueryKey extends readonly unknown[] 意味着推荐使用 as const 或字面量数组来写 key,例如 queryKey: ['todo', id] as const,这能进一步提升 key 结构在类型层的精确程度,并在 key 拼写变化时获得编译期提示。
七、使用注意事项
- 只做类型标记,不做默认值合并:queryOptions 不负责把 staleTime、retry 等选项与全局默认值合并,那是 QueryClient 初始化时的职责;它只是让「一份配置可以被安全地共享与复用」这一模式在类型层变得可用。
- 三个重载由 TypeScript 依据 options 形态自动选择:不必手动指定走哪一分支;当配置含必填的
initialData时命中DefinedInitialDataOptions,会获得数据不为 undefined 的更精确推断,反之(数据可能为 undefined)则命中UndefinedInitialDataOptions分支。 - Angular 版与 React 版机制一致,但使用形态有差异:本仓库中对应包为 angular-query-experimental,其框架文档见 docs/framework/angular,配合 injectQuery、injectQueries 等相关 API 使用即可发挥完整价值。
结语
queryOptions 是一个把「类型安全」贯彻到查询配置共享场景的轻量设计:运行时零成本(恒等返回原对象),类型层用 DataTag 符号体系把 queryKey 与 queryFn 的数据/错误类型牢牢绑定,让 Angular 项目中的 Service 层、组件层与 QueryClient 工具方法共享同一份类型可信的查询配置。在大型 Angular 应用中,它是组织查询配置、消除 unknown、减少手工断言的有效抓手。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0626
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00