Preact Query 的 TypeScript 类型安全实战指南:推断、收窄、error/meta/key 注册与 queryOptions
【免费下载链接】query🤖 Powerful asynchronous state management, server-state utilities and data fetching for the web. TS/JS, React Query, Solid Query, Svelte Query and Vue Query.项目地址: https://gitcode.com/GitHub_Trending/qu/query
TanStack Query 的官方类型体系会在数据获取、缓存与状态管理链路中自动传播类型,让 Preact 应用在使用@tanstack/preact-query时无需手写大量泛型也能获得端到端的类型安全。本篇指南将带你在 Preact + Preact Query 项目中系统掌握:版本支持策略、useQuery的结果类型推断与基于status的判别联合收窄、error字段的精确化(含全局Register注册)、meta与查询键的类型注册,以及用queryOptions/mutationOptions在 Hook 与命令式 API 之间共享类型化配置、用skipToken类型安全地禁用查询。读完你将具备"零显式泛型也能全链路类型安全"的工程能力。
说明:本文对应的官方页面 docs/framework/preact/typescript.md 在文档体系中通过
ref: docs/framework/react/typescript.md+replace: { 'react-query': 'preact-query', 'React': 'Preact' }规则生成,因此下文所有代码均为针对@tanstack/preact-query的版本,相关实现可直接在仓库的 packages/preact-query 中核对。
版本支持与类型升级策略
Preact Query(TanStack Query 家族在 Preact 上的适配层)本身完全用TypeScript编写,目的是让库本身和你的项目同时获得类型安全:
- 遵循 DefinitelyTyped 的支持窗口(support window),支持最近两年内发布的 TypeScript 版本;在本文撰写时对应TypeScript 5.6 及以上。
- 仓库内类型的变更一律被视为**非破坏性(non-breaking)**改动,通常以patch版本号发布——否则每一次类型增强都会变成一次 major 升级。
- 因此官方强烈建议把依赖锁定到具体的 patch 版本再升级,并预期任意两次 release 之间类型可能被修复或增强。
- 与类型无关的公开 API 仍严格遵循 semver 语义化版本。
这套策略的落地形态可以在包的入口 packages/preact-query/src/index.ts 中看到:preact-query通过export * from '@tanstack/query-core'完整转发核心类型与逻辑,再叠加自己的 Hook 层(useQuery、useSuspenseQuery、useInfiniteQuery、useMutation、useQueries、useIsMutating等)以及queryOptions、mutationOptions、infiniteQueryOptions这些辅助工厂。类型参数默认值、结果类型别名等全部集中在 packages/preact-query/src/types.ts。
类型推断:让类型沿着数据链路自动流动
Preact Query 的类型通常具备极好的穿透性,绝大多数场景不需要你手动标注泛型:
import { useQuery } from '@tanstack/preact-query' const { data } = useQuery({ // ^? const data: number | undefined queryKey: ['test'], queryFn: () => Promise.resolve(5), })一旦使用了select,data的类型会自动变成select的返回类型(缓存里仍是完整原始数据,只是你看到的data被变换了):
const { data } = useQuery({ // ^? const data: string | undefined queryKey: ['test'], queryFn: () => Promise.resolve(5), select: (data) => data.toString(), })从源码看,这种推断由UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>的泛型链条驱动(见 packages/preact-query/src/types.ts#L164-L172):TQueryFnData由queryFn返回类型推导,TData默认等于TQueryFnData、在传了select时收敛为select的输出类型。
给 queryFn 一个明确的返回类型
类型穿透效果最好的前提是queryFn具备良定义(well-defined)的返回类型。注意绝大多数数据请求库默认返回any,因此建议把取数逻辑抽成显式返回类型的函数,而不是内联裸写:
const fetchGroups = (): Promise<Group[]> => axios.get('/groups').then((response) => response.data) const { data } = useQuery({ queryKey: ['groups'], queryFn: fetchGroups }) // ^? const data: Group[] | undefined内联调用时,useQuery采用对象参数重载,而为了应对"有initialData(数据永不undefined)/无initialData"两种情况,packages/preact-query/src/useQuery.ts 提供了多重重载:设置了initialData的重载返回DefinedUseQueryResult(status为success或error,不会出现pending),未设置时返回UseQueryResult。这也是为什么"同一份参数放不放进initialData"会直接影响data是否带| undefined。
类型收窄:基于 status 的判别联合
查询结果使用以status字段为判别条件的判别联合类型(discriminated union),并派生出对应的布尔标志(isPending/isSuccess/isError等)。因此可以先检查success/isSuccess,TypeScript 就能自动把data收窄为已定义值:
const { data, isSuccess } = useQuery({ queryKey: ['test'], queryFn: () => Promise.resolve(5), }) if (isSuccess) { data // ^? const data: number }这一结果类型的根在@tanstack/query-core的QueryObserverResult/DefinedQueryObserverResult上,Preact 侧通过 packages/preact-query/src/types.ts#L313-L355 的UseQueryResult、DefinedUseQueryResult直接转发,保证 Hook 返回形态与核心观测器一致。
给 error 字段精确类型
error的默认类型是Error,这符合大多数使用者的预期:
const { error } = useQuery({ queryKey: ['groups'], queryFn: fetchGroups }) // ^? const error: Error如果你确实想抛自定义错误、甚至完全不是Error的东西,也可以显式指定 error 的类型:
const { error } = useQuery<Group[], string>(['groups'], fetchGroups) // ^? const error: string | null但代价是:一旦你显式传入第一个泛型,其余泛型(如TData、TQueryKey)就不再自动推断。因此官方普遍不推荐抛出非Error的值。
如果你抛出的是Error的子类(例如AxiosError),更好的做法是让error保持默认的Error,再借助类型守卫做收窄:
import axios from 'axios' const { error } = useQuery({ queryKey: ['groups'], queryFn: fetchGroups }) // ^? const error: Error | null if (axios.isAxiosError(error)) { error // ^? const error: AxiosError }注册一个全局 Error 类型
TanStack Query v5 提供了无需在调用处写泛型、却能统一全项目error类型的机制——扩充Register接口。这样既能保住类型推断,又能让 error 字段变成指定类型;如果希望强制每个调用方都做显式收窄,把defaultError设为unknown:
import '@tanstack/preact-query' declare module '@tanstack/preact-query' { interface Register { // 用 unknown,让所有调用处都必须显式收窄。 defaultError: unknown } } const { error } = useQuery({ queryKey: ['groups'], queryFn: fetchGroups }) // ^? const error: unknown | nullRegister接口的真实定义位于核心类型文件 packages/query-core/src/types.ts#L37-L43,默认注释给出了各可选槽位:defaultError、queryMeta、mutationMeta、queryKey、mutationKey。紧接着它通过条件类型读取Register推断出DefaultError(packages/query-core/src/types.ts#L45-L49),而DefaultError正是preact-query各选项类型(packages/preact-query/src/types.ts)中TError = DefaultError的默认取值来源。
注册全局 Meta 类型
与注册全局 error 类似,也可以注册全局的Meta类型,从而让查询与变更(mutation)的可选meta字段保持一致并类型安全。注意:注册的meta类型必须继承Record<string, unknown>,以保证meta仍然是对象:
import '@tanstack/preact-query' interface MyMeta extends Record<string, unknown> { // 你的 meta 类型定义。 } declare module '@tanstack/preact-query' { interface Register { queryMeta: MyMeta mutationMeta: MyMeta } }这样,传入useQuery/queryClient各处选项里的meta都会被统一约束为MyMeta。queryMeta/mutationMeta字段作用范围可分别对照查询与变更的参考文档:useQuery、useMutation。
注册 queryKey 与 mutationKey 类型
同样借助Register,你还可以注册全局的QueryKey和MutationKey类型,让你的 key 结构匹配应用自身的业务层级,并在库的全部 API 表面得到类型约束。注意:注册的 key 类型必须继承Array,保证 key 仍然是数组:
import '@tanstack/preact-query' type QueryKey = ['dashboard' | 'marketing', ...ReadonlyArray<unknown>] declare module '@tanstack/preact-query' { interface Register { queryKey: QueryKey mutationKey: QueryKey } }核心侧在解析QueryKey时同时接受Array<unknown>与ReadonlyArray<unknown>两种形态,否则回退到默认的ReadonlyArray<unknown>(packages/query-core/src/types.ts#L51-L59)。
提取并共享 Query Options:queryOptions 工厂
把选项内联在useQuery里时能自动推断类型;但当你想把查询配置抽成独立函数、同时在useQuery与命令式queryClient.query(或prefetch)等入口间共享时,会丢失推断。这时要用queryOptions把类型"找回来":
import { queryOptions } from '@tanstack/preact-query' function groupOptions() { return queryOptions({ queryKey: ['groups'], queryFn: fetchGroups, staleTime: 5 * 1000, }) } useQuery(groupOptions()) queryClient.query(groupOptions())更进一步,queryOptions返回的queryKey携带了与它关联的queryFn的类型信息,我们可以利用这个信息让queryClient.getQueryData也感知到数据类型:
const data = queryClient.getQueryData(groupOptions().queryKey) // ^? const data: Group[] | undefined如果不使用queryOptions,getQueryData得到的data类型将是unknown,除非手动传入泛型:
const data = queryClient.getQueryData<Group[]>(['groups'])它是怎么做到的?
查看实现 packages/preact-query/src/queryOptions.ts:queryOptions是个纯恒等函数(export function queryOptions(options: unknown) { return options }),它的类型重载会为返回的选项补上一个交叉类型QueryKeyWithDataTag<TQueryKey, TQueryFnData, TError>。这个 DataTag 标记就是核心里getQueryData、queryClient.query等 API 能从queryKey反推出数据与错误类型的依据(DataTag 相关的类型基础设施见 packages/query-core/src/types.ts#L61-L84)。
同时queryOptions.ts的三个重载把参数细分成了三种情况:
DefinedInitialDataOptions:设置了initialData,data永不undefined;UndefinedInitialDataOptions:未设置initialData,且允许queryFn: skipToken;UnusedSkipTokenOptions:未设置initialData,queryFn不允许是skipToken。
针对每种场景,文档注释都解释了真实的行为边界——例如"只有enabled: false或定义了默认 query 函数时才能省略queryFn,否则仍会发起请求并以Missing queryFn失败;initialData并不能阻止这次请求"。读懂这些重载,你就能精确预判data到底带不带| undefined。
getQueriesData 的例外
注意:queryOptions的类型推断对queryClient.getQueriesData不生效,因为它返回的是由异构、unknown数据组成的元组数组。如果你确定这些查询返回的数据类型,请显式指定:
const entries = queryClient.getQueriesData<Group[]>(groupOptions().queryKey) // ^? const entries: Array<[QueryKey, Group[] | undefined]>提取 Mutation Options:mutationOptions 工厂
与queryOptions类似,mutationOptions可以把变更配置抽成独立函数,并在useMutation、useIsMutating、queryClient.isMutating等入口间共享类型:
function groupMutationOptions() { return mutationOptions({ mutationKey: ['addGroup'], mutationFn: addGroup, }) } useMutation({ ...groupMutationOptions(), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['groups'] }), }) useIsMutating(groupMutationOptions()) queryClient.isMutating(groupMutationOptions())从 packages/preact-query/src/index.ts#L54 可以看到mutationOptions与queryOptions一样由preact-query导出;其类型基础UseMutationOptions<TData, TError, TVariables, TOnMutateResult>在 packages/preact-query/src/types.ts#L411-L419,最后一个泛型TOnMutateResult专为乐观更新的回滚数据设计——onMutate的返回值会被传递给onSuccess/onError/onSettled。
用 skipToken 类型安全地禁用查询
如果使用 TypeScript,可以用skipToken来禁用查询:当你希望基于某个条件暂时禁用查询、又不想破坏类型安全时,它是最合适的手段。它是被禁用时的合法queryFn值:
import { skipToken, useQuery } from '@tanstack/preact-query' function Post({ postId }: { postId: number | undefined }) { const { data, isLoading, isError, error } = useQuery({ queryKey: ['post', postId], queryFn: postId != null ? () => fetchPost(postId) : skipToken, }) if (postId == null) return 'Select a post' if (isLoading) return 'Loading...' if (isError) return <span>Error: {error.message}</span> return <h1>{data?.title}</h1> }底层实现里skipToken是一个唯一的 Symbol 哨兵(packages/query-core/src/utils.ts#L434-L435),核心在处理选项时若发现queryFn === skipToken会直接跳过执行,并在真正被触发时抛出一条明确错误(packages/query-core/src/utils.ts#L448-L452)。这也是UndefinedInitialDataOptions类型上唯一允许queryFn: skipToken的重载。更完整的禁用策略(与enabled的取舍、refetch 行为差异等)可参阅 禁用查询指南。
实战要点小结
- 能推断就别写泛型:
useQuery的对象参数写法 + 类型明确的queryFn已能覆盖绝大多数场景;一旦写显式泛型,其余泛型的推断就会失效。 - 用判别联合代替可选链:先判断
status === 'success'/isSuccess再访问data,即可免去到处?.。 - error 统一走
Error+ 收窄:自定义异常类型用类型守卫收窄,全局默认用Register['defaultError'](需要强制时设unknown)。 - 抽出即注册:跨 Hook 与命令式 API 共享的选项一律走
queryOptions/mutationOptions,数据标签让getQueryData等命令式读取也具备类型。 - 禁止状态用
skipToken:比enabled: false更贴近类型流,尤其适合"参数尚未就绪"的依赖查询场景。
更多延伸阅读可关注官方系列文章中关于类型推断技巧、类型安全最大化以及 Query Options API 设计的内容;若想深入源码级验证,建议直接阅读 packages/preact-query/src/queryOptions.ts、packages/preact-query/src/types.ts、packages/query-core/src/types.ts 三份类型文件,并结合 packages/query-core/src/tests/queryClient.test-d.tsx 中针对skipToken与select的类型级测试用例理解设计意图。
【免费下载链接】query🤖 Powerful asynchronous state management, server-state utilities and data fetching for the web. TS/JS, React Query, Solid Query, Svelte Query and Vue Query.项目地址: https://gitcode.com/GitHub_Trending/qu/query
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考