@tanstack/lit-query 的 infiniteQueryOptions:让 queryKey 承载无限查询数据类型,实现跨 API 的类型安全
本文讲解 @tanstack/lit-query 提供的类型辅助函数 infiniteQueryOptions:它在编译期将「分页数据形状(InfiniteData)」与「错误类型」以品牌标记(DataTag)的形式绑定到 queryKey 上,使同一份 options 在 createInfiniteQueryController、queryClient.getQueryData、queryClient.setQueryData、queryClient.infiniteQuery 等 TanStack Query API 之间传递时保持精确的类型推断。读完本文,你将理解该函数的签名、五个泛型参数的职责、运行时零成本的实现原理,以及如何在 Lit 组件中把它与无限查询控制器组合出带类型保护的「加载更多」分页方案。
函数签名与核心作用
infiniteQueryOptions 的完整签名如下(该文档定义见 packages/lit-query/src/infiniteQueryOptions.ts,源码中函数文档注释亦与其一致):
function infiniteQueryOptions<TQueryFnData, TError, TData, TQueryKey, TPageParam>(options): InfiniteQueryObserverOptions<TQueryFnData, TError, TData, TQueryKey, TPageParam> & object;
其官方说明是:
Brands infinite query options so the
queryKeycarries the infinite query data and error types across TanStack Query APIs.
即:对无限查询 options 做"品牌化"(branding),使 queryKey 能够把无限查询的数据类型与错误类型携带到 TanStack Query 的各条 API 中。
之所以需要这样一个辅助函数,是因为无限查询的缓存值结构特殊——它不是单页数据,而是 { pages: TPage[]; pageParams: TPageParam[] } 的聚合结构(下文详述)。若直接手写 queryKey: ['projects'] 并交给 queryClient.setQueryData、queryClient.prefetchInfiniteQuery 等方法,TypeScript 无从得知该 key 对应的数据到底是什么;而经过 infiniteQueryOptions 包装后,查询键的类型信息(数据与错误类型)会随对象一起传播,调用方无需重复声明泛型即可拿到精确的读写类型。
五个类型参数逐一解析
infiniteQueryOptions 是一个完全类型层面的函数,其泛型顺序与 InfiniteQueryObserverOptions 保持一致,每个参数的含义与默认值如下:
| 类型参数 | 约束 / 默认值 | 含义 |
|---|---|---|
TQueryFnData |
默认 unknown |
queryFn 每次调用返回的单页数据类型。这是最核心的一层,后面的 InfiniteData 由它推导而来 |
TError |
默认 Error |
请求失败时 error 字段的类型。文档默认值为 Error;在 query-core 的源码 中该默认通过 DefaultError 表达——它由全局 Register 接口解析,未增补时解析结果即 Error,因此在大多数项目中二者等价 |
TData |
默认 InfiniteData<TQueryFnData> |
查询结果 data 的整体形状。无限查询默认会把这 N 页数据聚合成 InfiniteData,即 { pages: Array<TData>; pageParams: Array<TPageParam> }(见 types.ts 中 InfiniteData 的定义)。只有显式传入 select 转换结果时才需要改这一层 |
TQueryKey |
extends readonly unknown[],默认 readonly unknown[] |
queryKey 的类型。传入 ['projects'] as const 之类的常量元组可让 key 的字面量精确化,便于与服务端/缓存命中保持一致 |
TPageParam |
默认 unknown |
分页参数类型,即传给 queryFn 上下文 pageParam 字段的类型,同时也决定了 pageParams 数组的元素类型。当 queryKey 携带品牌信息后,InfiniteData<TQueryFnData, TPageParam> 会把该类型一并刻进 queryKey |
参数、返回值与官方示例
参数 options
options: InfiniteQueryObserverOptions<TQueryFnData, TError, TData, TQueryKey, TPageParam>
这是待"保存并品牌化"的无限查询配置对象,其形状在 query-core 中被定义为 InfiniteQueryObserverOptions,包含 queryKey、queryFn、initialPageParam、getNextPageParam、getPreviousPageParam、maxPages 等无限查询专属字段。其中 queryFn 收到的上下文为 QueryFunctionContext,pageParam 字段的类型正是泛型 TPageParam。
返回值
函数返回同一个 options 对象,其类型被收窄为:
InfiniteQueryObserverOptions<TQueryFnData, TError, TData, TQueryKey, TPageParam> & {
queryKey: DataTag<TQueryKey, InfiniteData<TQueryFnData>, TError>
}
也就是说:原有配置项一个不少,额外多出的唯一变化是 queryKey 被升级为携带数据/错误类型的品牌键。
文档给出的示例
import { infiniteQueryOptions } from '@tanstack/lit-query'
const projectsOptions = infiniteQueryOptions({
queryKey: ['projects'],
queryFn: ({ pageParam }) => fetchProjects(pageParam),
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor,
})
运行时零成本:实现只有"原样返回"
很多人会好奇品牌化是否带来运行开销。查看 infiniteQueryOptions.ts 的实现 可以发现,除去类型签名后的函数体只有一行:
export function infiniteQueryOptions(options: unknown) {
return options
}
它不克隆、不改写、不冻结传入的对象,也不在 queryKey 上注入任何真实属性——所有信息都只存在于 TypeScript 类型层面。这意味着:
- 它是纯编译期工具,与
queryOptions(见 queryOptions.ts)、mutationOptions(见 mutationOptions.ts)属于同一族辅助函数,三者都在 lit-query 的入口文件 中一并导出; - 由于返回的是原对象引用,把它传入依赖引用相等性做优化的场景(如
staleTime判定、effect 去重)也完全安全; - 唯一的"成本"发生在编辑期——换来的是所有下游 API 的精确类型。
底层原理:DataTag 如何让 queryKey 携带类型
品牌化机制由 query-core 的类型体系支撑。在 query-core 的 types.ts 中:
export const dataTagSymbol = Symbol()
export const dataTagErrorSymbol = Symbol()
export type dataTagErrorSymbol = typeof dataTagErrorSymbol
export type DataTag<TType, TValue, TError = UnsetMarker> =
TType extends AnyDataTag ? TType
: TType & {
[dataTagSymbol]: TValue
[dataTagErrorSymbol]: TError
}
DataTag 借助两个唯一的 Symbol 键作为"品牌的纹章",把两类元数据织入类型:
[dataTagSymbol]:保存数据类型,对无限查询而言即InfiniteData<TQueryFnData>;[dataTagErrorSymbol]:保存错误类型TError。
查询键因此从"只是一个数组"变成"知道自己是哪份无限查询缓存的键"。随后 query-core 通过两个推断工具类型还原这些信息:
- InferDataFromTag:从带标签的 key 中取出
TaggedValue; - InferErrorFromTag:从带标签的 key 中取出
TaggedError(无标签时回落到TError)。
凡是接收查询键的 API——如 getQueryData、setQueryData、fetchQuery、prefetchInfiniteQuery 等——都会走这条还原链路,于是 queryKey 携带的类型信息得以跨 API 传播。这也解释了为什么 createInfiniteQueryController 的 options 参数类型 CreateInfiniteQueryOptions(见 createInfiniteQueryController.ts)会原样继承 InfiniteQueryObserverOptions:控制器内部通过 client.defaultQueryOptions(...) 处理传入的 options(同一文件),被品牌化后的 options 无论传给控制器、Observer 还是缓存方法,类型都不会"掉线"。
实战:与 createInfiniteQueryController 组合成类型安全的分页
无限查询控制器的用法记录在 Lit 框架的 infinite-queries 指南。把 infiniteQueryOptions 定义好的配置直接交给 createInfiniteQueryController,即可在控制器、缓存读写与指令式翻页之间共享同一份类型:
import { LitElement, html } from 'lit'
import {
infiniteQueryOptions,
createInfiniteQueryController,
} from '@tanstack/lit-query'
type ProjectPage = {
projects: Array<{ id: number; name: string }>
nextCursor?: number
}
const projectsOptions = infiniteQueryOptions({
queryKey: ['projects'] as const,
queryFn: ({ pageParam }) => fetchProjectsPage(pageParam),
initialPageParam: 1,
getNextPageParam: (lastPage) =>
lastPage.nextCursor ?? undefined,
})
class ProjectsList extends LitElement {
private readonly projects = createInfiniteQueryController(this, projectsOptions)
render() {
const query = this.projects()
if (query.isPending) return html`Loading...`
if (query.isError) return html`Error: ${query.error.message}`
return html`
${query.data.pages.map(
(page) => html`
${page.projects.map((project) => html`<p>${project.name}</p>`)}
`,
)}
<button
?disabled=${!query.hasNextPage || query.isFetching}
@click=${() => this.projects.fetchNextPage()}
>
${query.isFetchingNextPage ? 'Loading more...' : 'Load More'}
</button>
`
}
}
这里的结果对象 query.data 自动被推断为 InfiniteData<ProjectPage> | undefined,因此 query.data.pages 中每个元素的类型都是精确的 ProjectPage。
控制器返回的 accessor 还额外暴露了 refetch、fetchNextPage、fetchPreviousPage、destroy 方法,它们都委托给底层 InfiniteQueryObserver(见 createInfiniteQueryController.ts)。若组件未位于 QueryClientProvider(QueryClientProvider.ts)上下文内,这些方法会以 No QueryClient available 错误确定性地失败,控制器进入占位 pending 状态——测试用例 LC-INF-01 / LC-INF-03 对此有专门覆盖。
当配置依赖组件响应式状态时
Lit 的控制器体系支持把 options 作为函数传入:当 options 是函数时,控制器会在宿主更新期间重新读取(见 createInfiniteQueryController.ts),因此 queryKey 可以跟随宿主响应式状态变化。此时把 infiniteQueryOptions 放进 getter 即可:
private readonly projects = createInfiniteQueryController(
this,
() => infiniteQueryOptions({
queryKey: ['projects', this.categoryId] as const,
queryFn: ({ pageParam }) => fetchProjectsPage(this.categoryId, pageParam),
initialPageParam: 1,
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
}),
)
跨 API 复用:缓存读写也能拿到精确类型
infiniteQueryOptions 最实用的价值,是把"选项定义一次,全项目类型一致"落实到位。仓库的类型测试 type-inference.test.ts 验证了下列链路:
const infiniteQueryOpts = infiniteQueryOptions({
queryKey: ['type-inference', 'infinite-query-options'] as const,
initialPageParam: 0,
queryFn: async () => ({ page: 3 }),
getNextPageParam: (lastPage) => lastPage.page + 1,
})
// 品牌刻在 queryKey 上:
// dataTagSymbol -> InfiniteData<{ page: number }>
// dataTagErrorSymbol -> Error
const cachedPages = client.getQueryData(infiniteQueryOpts.queryKey)
// cachedPages: InfiniteData<{ page: number }> | undefined
const updatedPages = client.setQueryData(infiniteQueryOpts.queryKey, {
pages: [{ page: 4 }],
pageParams: [0],
})
// updatedPages: InfiniteData<{ page: number }> | undefined
关键收益:
- 读缓存:
getQueryData不再返回unknown,而是InfiniteData<TPage> | undefined; - 写缓存:
setQueryData的入参与回调参数(如乐观更新中基于上一份数据合并新页)都被限定为正确的InfiniteData结构,传入{ pages: [...] }之外的错误形状会直接编译报错; - 命令式无限查询:
client.infiniteQuery(options)也能借助品牌键还原出完整类型,测试 L7–L9(type-inference.test.ts)进一步验证了select转换(返回data.pages数组)与enabled: false组合时同样成立。
同时,集成测试 OPT-01(infinite-and-options.test.ts)从运行时角度证实:把 infiniteQueryOptions 包装的配置交给 createInfiniteQueryController 后,数据可正常请求并聚合为 data.pages,证明该函数在"类型增强"之外对运行时行为毫无侵入。
与 queryOptions、mutationOptions 的分工
infiniteQueryOptions 并非孤立设计,它和同一包的 queryOptions(普通查询)与 mutationOptions(变更操作)构成一组配套的类型入口:
| 辅助函数 | 绑定到 queryKey 的数据类型 | 面向场景 |
|---|---|---|
queryOptions |
普通 TQueryFnData(单页直出) |
单次请求、按 key 缓存 |
infiniteQueryOptions |
InfiniteData<TQueryFnData, TPageParam>(pages + pageParams 聚合) |
无限滚动、"加载更多"列表 |
mutationOptions |
无(变更不共享查询键) | 写操作,配合失效与乐观更新 |
三者的实现策略一致:均为重载 + options 原样返回的零运行时工具(packages/lit-query/src 下同名文件)。其中 queryOptions 还针对 initialData 是否存在拆分出 DefinedInitialDataOptions / UndefinedInitialDataOptions 等重载,用于在 data 非空的场景下收紧类型;infiniteQueryOptions 则保持单一重载,把所有类型信息统一通过 DataTag 收敛到 queryKey 上。
使用注意事项
- 类型仅存在于编译期:
DataTag是纯类型层面的品牌(虽然 query-core 导出了dataTagSymbol这一常量,但它只作为类型键使用),运行时queryKey仍是普通数组,不要试图在代码里读取queryKey[dataTagSymbol]获取真实值; - 让 queryKey 足够精确:要获得元组级推断,建议书写字面量 key(如
['projects', id] as const)或让类型参数显式参与推导,否则宽泛的string[]会削弱品牌效果; - 默认数据类型是聚合形态:经
infiniteQueryOptions处理后,读写缓存时操作的是InfiniteData(pages/pageParams)而非单页数据,这与无限查询"N 页共享一个缓存条目"的模型一致(参见 InfiniteData 类型定义); - 错误类型可全局定制:默认错误为
Error,如需自定义,可通过 query-core 的Register接口增补defaultError,或显式传入TError泛型。
小结
infiniteQueryOptions 用一行"原样返回"的运行时实现,为 Lit 应用中的无限查询换来了整条数据链路(控制器渲染、指令式翻页、缓存读写、命令式查询)上的一致且精确的类型推导。它把"这份缓存长什么样"这一信息封印在 queryKey 上,让 TanStack Query 的各条 API 都能"认出"彼此——这正是无限查询场景下减少 unknown、消除手写泛型的心智负担、并让乐观更新与缓存维护变得可编译校验的关键工具。它的实现与配套测试可分别在 packages/lit-query/src/infiniteQueryOptions.ts、type-inference.test.ts 与 infinite-and-options.test.ts 中继续深入研究。
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 StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python07
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00