《TypeScript编程实战》14.1 TanStack Query 类型推导

本节先厘清服务端状态与客户端状态的分工,再拆解 TanStack Query 如何把类型从 queryKey 推导到 data。你会掌握 useQuery 的四个类型参数、key factory 的收敛写法、select 收窄、queryOptions 复用与自定义 Hook 泛型封装,以及 error 类型必须显式声明等高频坑,最终写出一层端到端有类型的查询代码。

本节目标:先分清「服务端状态」和「客户端状态」这两种完全不同的东西,再掌握 TanStack Query 的类型推导链路——从 queryKey 到 queryFn 返回值,再到组件里的 data。读完本节,你应该能写出一个类型完全闭环、错误分支被强制处理的数据获取层,并且知道哪些地方必须显式标注类型、哪些地方交给推导就好。

14.1 TanStack Query 类型推导

先看一段几乎人人都写过的组件:

function UserCard({ id }: { id: string }) {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    let alive = true;
    setLoading(true);
    fetch(`/api/users/${id}`)
      .then((r) => r.json())
      .then((d) => alive && setUser(d))
      .catch((e) => alive && setError(String(e)))
      .finally(() => alive && setLoading(false));
    return () => {
      alive = false;
    };
  }, [id]);
  return loading ? <Spinner /> : error ? <Err msg={error} /> : <Card user={user!} />;
}

这段代码没有语法错误,pnpm typecheck 也是绿的。但 .json() 的返回类型是 any,所以 setUser(d) 这个赋值绕过了所有检查——后端把 nickname 改成 nick_name,编译期一无所知。而结尾那个 user! 非空断言,是类型系统在向你发出求救信号。

14.1.1 两种状态,两套工具

手写 useEffect 的问题不只是「类型丢了」,而是它把四件本该由框架负责的事塞给了每个组件:缓存、去重、重试、失效。要理解 TanStack Query 为什么值得引入,先要接受一个分类:

维度客户端状态服务端状态
数据归属前端自己产生并持有后端是唯一真相源
典型例子弹窗开关、表单草稿、主题用户列表、订单详情、配置
一致性要求本地即时生效即可随时可能被别人改掉
生命周期随组件挂载卸载跨页面、跨会话长期存在
合适的工具Zustand / Redux / ContextTanStack Query / SWR

把两者混在一个 store 里,是前端状态管理最常见的架构错误:你会被迫手写 isStale、refetchOnFocus、invalidate 这些本不属于客户端状态的概念。客户端状态那部分见 《TypeScript编程实战》11.3 Context 与状态管理(Zustand / RTK) ,本节只谈右边一列。

14.1.2 useQuery 的四个类型参数

useQuery 的签名(简化)长这样:

declare function useQuery<
  TQueryFnData = unknown,
  TError = Error,
  TData = TQueryFnData,
  TQueryKey extends QueryKey = QueryKey,
>(options: UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>): UseQueryResult<TData, TError>;

四个参数的分工见 TanStack Query 类型文档 ,其中错误类型不会由 throw 推导:

参数含义谁来提供
TQueryFnDataqueryFn 的返回类型从函数返回值推导
TError错误类型默认 Error;显式参数或全局 Register 可改写,不能从 throw 推导
TDataselect 之后的类型从 select 返回值推导
TQueryKeyqueryKey 的精确类型从传入的 key 推导

关键在于:这四个参数几乎都不需要手写。只要 queryFn 有明确返回类型、queryKey 是字面量数组,推导就自然成立。下面是一个最小闭环:

interface User {
  id: string;
  name: string;
  email: string;
}

async function fetchUser(id: string): Promise<User> {
  const res = await fetch(`/api/users/${id}`);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json() as Promise<User>;
}

const { data } = useQuery({
  queryKey: ['user', id],
  queryFn: () => fetchUser(id),
});
// data: User | undefined

data 是 User | undefined,而不是 any——这就是引入 Query 最直接的收益。注意 undefined 不是多余的:首次加载、或者查询被禁用时,data 确实是空的。类型系统在这里没有撒谎。

顺带看两个派生字段的类型,它们比 data 更常用:

const q = useQuery({ queryKey: ['user', id], queryFn: () => fetchUser(id) });
q.isSuccess; // boolean(不是类型守卫)
q.isPending; // boolean,v5 里替代了 isLoading 的语义
q.status;    // 'pending' | 'error' | 'success'

status 是判别联合,所以可以用 switch 精确窄化;而 isSuccess 只是 boolean,它不会帮你把 data 收窄成非空。这是 v5 用户最容易踩的一个直觉陷阱,正确写法是用 status 或 data !== undefined 判断。

14.1.3 queryKey 才是类型的源头

上面那段推导能成立,前提是 queryKey 是字面量数组。如果你把它抽成一个变量而不加 as const,就会出问题:

const key = ['user', id]; // string[]
const { data } = useQuery({ queryKey: key, queryFn: () => fetchUser(id) });

大多数情况下这仍然能跑,但 TQueryKey 会被推成 string[],你失去了「这个 key 只能配这个 queryFn」的约束力。工程上更可靠的做法是 key factory——把 key 的构造集中到一个对象里:

export const userKeys = {
  all: ['users'] as const,
  lists: () => [...userKeys.all, 'list'] as const,
  list: (filter: UserFilter) => [...userKeys.lists(), filter] as const,
  details: () => [...userKeys.all, 'detail'] as const,
  detail: (id: string) => [...userKeys.details(), id] as const,
};

as const 是这里的灵魂:它把 ['users'] 从 string[] 收窄成 readonly ['users'],后面的 invalidateQueries({ queryKey: userKeys.all }) 才能按前缀精确匹配。这一点在 《TypeScript编程实战》14.2 乐观更新与缓存失效 会展开,现在只需要记住:key 的类型精度决定了失效的精度。

14.1.4 queryOptions:让配置可复用的官方姿势

v5 引入的 queryOptions 是本节最值得单独记一个 API。它把「key + queryFn」打包成一个普通对象,同时保留了全部类型信息:

import { queryOptions } from '@tanstack/react-query';

export const userDetailQuery = (id: string) =>
  queryOptions({
    queryKey: userKeys.detail(id),
    queryFn: () => fetchUser(id),
    staleTime: 30_000,
  });

这个对象可以在三个地方复用,且类型完全一致:

// 1. 组件里
const { data } = useQuery(userDetailQuery(id));
// 2. 预取
await queryClient.prefetchQuery(userDetailQuery(id));
// 3. 缓存读写(key 自动对上)
queryClient.getQueryData(userDetailQuery(id).queryKey);
//            ^? User | undefined

最后一行是 queryOptions 真正的价值:queryKey 与 queryFn 的类型被绑定在一起,getQueryData 能据此推导出缓存值的类型。手写 queryClient.getQueryData(['user', id]) 只会得到 unknown,你就不得不加断言——而断言正是我们想消灭的东西。

14.1.5 推导结果验证与 select 收窄

想知道推导到底给了什么类型,最省事的办法是让编译器自己回答:

const q = useQuery(userDetailQuery(id));
type R = typeof q;
//   ^? type R = UseQueryResult<User, Error>

select 是把 TData 与 TQueryFnData 分开的那个参数。它接收完整的 User,返回你真正要用的形状:

const { data } = useQuery({
  ...userDetailQuery(id),
  select: (user) => ({ label: user.name, value: user.id }),
});
// data: { label: string; value: string } | undefined

这里有三个必须知道的约束。第一,select 必须稳定引用,否则每次渲染都重算;通常抽成模块级函数或 useCallback。第二,select 只在缓存命中后执行,它不改变缓存里存的内容——缓存里永远是完整的 User。第三,select 一旦抛出,data 不会更新而 isError 会翻转,所以不要在 select 里做有副作用的转换。

14.1.6 并行查询与类型保持

一个页面要同时拉多个资源时,不要写多个 useQuery 再手动合并,用 useQueries:

const results = useQueries({
  queries: ids.map((id) => userDetailQuery(id)),
});
// results: Array<UseQueryResult<User, Error>>
const users = results.flatMap((r) => (r.data ? [r.data] : []));
// users: User[]

useQueries 的返回类型是同构数组,所以 r.data 依旧是 User | undefined。如果你需要「每个查询返回不同结构」,可以给数组加 as const,此时返回类型会变成元组,逐项类型各自独立:

const [users, stats] = useQueries({
  queries: [userListQuery(), statsQuery()] as const,
});
// users.data: User[] | undefined
// stats.data: Stats | undefined

这个 as const 的用法知道就好,日常更推荐把异构查询拆成两个 Hook,可读性更好。

14.1.7 把查询封进自定义 Hook

组件里堆 query 配置迟早会重复。标准做法是每个资源配一个 Hook,把类型暴露出去:

export function useUser(id: string) {
  return useQuery({
    ...userDetailQuery(id),
    enabled: id !== '',
  });
}

export function useUserList(filter: UserFilter) {
  return useQuery({
    queryKey: userKeys.list(filter),
    queryFn: () => fetchUsers(filter),
  });
}

调用方拿到的是 UseQueryResult<User, Error>,不用重复声明泛型。如果你想让多个 Hook 共享同一套「错误已归一化」的约定,可以再包一层泛型工具:

function useTypedQuery<TData>(options: UseQueryOptions<TData, ApiError, TData, QueryKey>) {
  return useQuery<TData, ApiError, TData, QueryKey>(options);
}

const { data, error } = useTypedQuery({
  queryKey: userKeys.detail(id),
  queryFn: () => fetchUser(id),
});
// error: ApiError | null

这一层的价值在于把 TError 从默认的 Error 换成你自己的 ApiError(比如 《TypeScript编程实战》3.1 Result/Either 与类型化错误 里那套判别联合)。TypeScript 不会自动知道你的 fetch 封装抛的是 ApiError,错误类型必须显式声明——这是本节能给的最实用的一条经验。

14.1.8 五个常见坑

一、error 默认是 Error,但运行时未必。 如果 queryFn 里写的是 throw 'boom',error 依然被标注成 Error,error.message 在运行时是 undefined。要么保证只抛 Error 实例,要么显式把 TError 声明成 unknown 并在使用处用类型守卫收窄:

function toMessage(e: unknown): string {
  return e instanceof Error ? e.message : String(e);
}

二、useQuery 不能写在条件分支里。 它本质是 Hook,必须遵守调用顺序规则。条件查询要用 enabled 而不是 if:

useQuery({ ...userDetailQuery(id), enabled: !!id });

三、queryFn 返回 any 会让整条推导塌掉。 最常见的元凶是 res.json()。类型断言 as Promise<User> 不是最优解——更稳的是用 zod 在边界校验一次,让运行时与编译期同时可信:

const UserSchema = z.object({ id: z.string(), name: z.string(), email: z.string().email() });

async function fetchUser(id: string): Promise<User> {
  const res = await fetch(`/api/users/${id}`);
  if (!res.ok) throw new ApiError(res.status);
  return UserSchema.parse(await res.json()); // 运行时校验 + 类型推导双保险
}

四、key 里别塞对象。 queryKey: ['user', { id, tab }] 看着方便,但对象在哈希时按结构序列化,字段顺序不同会被当成两个 key。要么用数组 ['user', id, tab],要么在 key factory 里手动拼成稳定字符串。

五、staleTime 默认为 0。 这意味着窗口重新聚焦就会重取,本地开发时看起来像「请求发了两遍」。这不是 bug,是默认策略;按资源的易变性分别设定,比全局调一个值更合理。

14.1.9 与全书其它章节的衔接

queryFn 里的请求封装通常会复用第 5 章的 HTTP 客户端约定,见 《TypeScript编程实战》5.1 HTTP 服务与路由(Fastify / Hono) 。当后端与前端在同一个 TypeScript 仓库时,更彻底的方案是让 queryFn 直接调用带类型的 RPC 客户端,见 《TypeScript编程实战》16.1 tRPC 端到端类型安全 。若你还在 Vue 技术栈,同一套 Query 的类型模型可以平移,见 《TypeScript编程实战》12.1 Vue 3 组合式 API 类型 。自定义 Hook 本身的类型写法可回看 《TypeScript编程实战》11.2 Hooks 类型与自定义 Hook 。

站内已有专题对这套模式做过单点深挖,可作为延伸阅读:React 状态管理实践 、前端状态管理指南 、TypeScript 缓存策略 。Hooks 本身的类型细节可读 React Hooks 完全指南 。

小结

本节的核心是一条推导链:queryKey 提供类型精度,queryFn 提供数据类型,select 提供派生类型,TError 必须手动声明。把这四件事摆正,data 就是 User | undefined 而不是 any,error 就能被强制收窄,组件里那个 user! 也就可以删掉了。

工程上最值得坚持的三条纪律:一是把 queryKey 收进 key factory 并用 as const 保住字面量类型,因为失效与预取的精度完全取决于它;二是用 queryOptions 把配置打包,让组件、预取、缓存读写共享同一份类型;三是把错误类型显式声明成自己的 ApiError 判别联合,别让默认的 Error 掩盖运行时真相。

查询只是起点。用户点下「保存」之后,缓存要怎么改、什么时候重新拉取、失败如何回滚,是下一节的主题——《TypeScript编程实战》14.2 乐观更新与缓存失效 。

阅读导航:上一节:13.3 复杂表单与动态字段 · 下一节:14.2 乐观更新与缓存失效 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes