TypeScript 与类型安全基础
本教程共 38 篇 · 第 3 篇 · 更新于 2026-07-27 · 约 9 分钟阅读
3. TypeScript 与类型安全基础
本节目标:搞懂 TanStack 的类型安全怎么用、怎么推导、遇到类型问题怎么解决,学完你能写出类型严密的查询和路由代码,少踩 any 的坑。
3.1 TanStack 的类型安全哲学
先说个原则:TanStack 的目标是让你尽量少写类型。
听起来矛盾?类型安全不是应该多写类型吗?恰恰相反。好的类型安全是「自动推导」,你给运行时数据,TS 自己推类型,不用你手标。
TanStack Form 官方原话:写正确的 TanStack 代码时,除了对运行时值的类型标注,你不应该能区分这是 JavaScript 还是 TypeScript 用法。
Query 也遵循这个原则。queryFn 返回 Promise<number>,data 就自动是 number | undefined,一个泛型都不用传。
3.2 useQuery 的自动推导
看个最简单的例子:
const { data } = useQuery({
queryKey: ['test'],
queryFn: () => Promise.resolve(5),
})
// ^? const data: number | undefined
queryFn 返回 Promise<number>,data 自动推导成 number | undefined。undefined 是因为查询还没成功时 data 是没有的。
加了 select 也能推导。select 会改变 data 的类型:
const { data } = useQuery({
queryKey: ['test'],
queryFn: () => Promise.resolve(5),
select: (data) => data.toString(),
})
// ^? const data: string | undefined
select 接收 number,返回 string,data 就变成 string | undefined。
Tip自动推导要工作得好,前提是你的
queryFn有明确的返回类型。很多数据请求库(比如 axios)默认返回any,记得把请求函数单独抽出来标好类型:
// 标好返回类型
const fetchGroups = (): Promise<Group[]> =>
axios.get('/groups').then((response) => response.data)
const { data } = useQuery({ queryKey: ['groups'], queryFn: fetchGroups })
// ^? const data: Group[] | undefined
3.3 状态收窄(Type Narrowing)
Query 的返回结果是个可辨识联合类型(discriminated union),靠 status 字段区分。这意味着你可以用状态判断来收窄 data 的类型:
const { data, isSuccess } = useQuery({
queryKey: ['test'],
queryFn: () => Promise.resolve(5),
})
if (isSuccess) {
data
// ^? const data: number -- 这里 data 不是 undefined 了
}
判了 isSuccess 之后,data 自动收窄成 number,不用再 data! 非空断言。这种写法既安全又省心。
3.4 错误类型怎么处理
v5 有个重要变更:error 的默认类型从 unknown 变成了 Error。
为什么这么改?因为虽然 JS 里 throw 啥都行(所以 unknown 最正确),但实际项目里大家基本都 throw 一个 Error 或其子类。默认 Error 能让大多数场景少写类型标注。
const { error } = useQuery({ queryKey: ['groups'], queryFn: fetchGroups })
// ^? const error: Error | null
自定义错误类型
如果你想抛自定义错误,比如 axios 的 AxiosError,有两个办法。
办法一:用类型守卫收窄:
import axios from 'axios'
const { error } = useQuery({ queryKey: ['groups'], queryFn: fetchGroups })
// ^? const error: Error | null
if (axios.isAxiosError(error)) {
error
// ^? const error: AxiosError -- 收窄成具体类型
}
办法二:传泛型(不推荐,会让其他泛型推导失效):
// 这种写法会让所有泛型都得手写,不推荐
const { error } = useQuery<Group[], string>({
queryKey: ['groups'],
queryFn: fetchGroups,
})
// ^? const error: string | null
Warning官方不推荐抛非
Error的东西。如果一定要抛,考虑继承Error造个子类,而不是直接throw字符串或对象。
全局注册错误类型
如果你整个项目都用同一种自定义错误,v5 支持通过 Register 接口全局注册:
import '@tanstack/react-query'
declare module '@tanstack/react-query' {
interface Register {
// 用 unknown 强制调用方手动收窄
defaultError: unknown
}
}
const { error } = useQuery({ queryKey: ['groups'], queryFn: fetchGroups })
// ^? const error: unknown | null
设成 unknown 是最严格的,逼着每个调用方显式判断错误类型,比较适合对类型要求极致的项目。
3.5 queryOptions:类型穿透利器
前面提过 queryOptions,这里展开讲讲为什么它重要。
问题场景:你想把查询配置抽成函数复用,但抽出来之后类型丢了。
// 抽出来的函数,类型推导没了
function groupOptions() {
return {
queryKey: ['groups'],
queryFn: fetchGroups,
staleTime: 5 * 1000,
}
}
// 这里 data 变成 unknown 了
const data = queryClient.getQueryData(groupOptions().queryKey)
// ^? unknown
用 queryOptions 包一层,类型就回来了:
import { queryOptions } from '@tanstack/react-query'
function groupOptions() {
return queryOptions({
queryKey: ['groups'],
queryFn: fetchGroups,
staleTime: 5 * 1000,
})
}
const data = queryClient.getQueryData(groupOptions().queryKey)
// ^? Group[] | undefined -- 类型回来了
运行时 queryOptions 就是原样返回你传的东西,没任何开销。但在类型层面,它把 queryKey 和 queryFn 的关系记住了,让 getQueryData 知道这个 key 对应什么数据。
mutationOptions 是 mutation 版的同类工具,用法一样。
3.6 Register:模块级类型注册
TanStack 有个贯穿各库的 Register 机制。通过「声明合并」往模块接口上加类型,让全局导出的 API 都能用到你的类型。
Query 里能注册这些东西:
import '@tanstack/react-query'
declare module '@tanstack/react-query' {
interface Register {
defaultError: unknown // 全局错误类型
queryMeta: MyMeta // 查询的 meta 字段类型
mutationMeta: MyMeta // 变更的 meta 字段类型
queryKey: MyQueryKey // 查询键类型
mutationKey: MyQueryKey // 变更键类型
}
}
注册 queryKey 类型能给查询键加结构约束,让它符合你应用的层级规范:
type QueryKey = ['dashboard' | 'marketing', ...ReadonlyArray<unknown>]
declare module '@tanstack/react-query' {
interface Register {
queryKey: QueryKey
}
}
这样所有 queryKey 第一项只能是 'dashboard' 或 'marketing',写错就报错。
3.7 路由类型注册
Router 把 Register 机制用得更深。你的整个路由树类型都得注册上去:
const router = createRouter({
routeTree,
// ...
})
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}
注册之后,这些全局导出的 API 全都有类型:
<Link to="/posts" />—to只能是合法路由路径useParams()— 拿到的参数类型和路由定义一致useSearch()— search params 类型受约束useNavigate()— 导航目标有类型校验
写错路径、传错参数、search params 类型不对,统统编译报错。这是 React Router 给不了的体验。
路由上下文的类型
Router 还有个「路由上下文(Router Context)」机制,能往路由树里注入依赖(比如 QueryClient):
const rootRoute = createRootRouteWithContext<{
queryClient: QueryClient
}>()({
component: App,
})
const routeTree = rootRoute.addChildren([
// 所有子路由都能拿到 queryClient
])
const router = createRouter({
routeTree,
context: {
queryClient, // 这里必须传,类型会校验
},
})
createRootRouteWithContext 会把上下文类型贯穿整个路由树,每一层路由都能拿到注入的依赖,类型不会丢。
3.8 类型安全的查询禁用:skipToken
最后说个小但实用的特性。有时你想根据条件禁用查询,通常用 enabled: false。但 TypeScript 用户有个更类型安全的写法:skipToken。
import { skipToken, useQuery } from '@tanstack/react-query'
function Todos() {
const [filter, setFilter] = React.useState<string | undefined>()
const { data } = useQuery({
queryKey: ['todos', filter],
// filter 为空时传 skipToken,类型仍然安全
queryFn: filter ? () => fetchTodos(filter) : skipToken,
})
}
skipToken 是个特殊的标记值,传给它等于禁用查询,但 queryFn 的类型签名不会被破坏。
Warning用了
skipToken之后,useQuery返回的refetch就不能用了,调用会报Missing queryFn错误。需要手动触发查询的场景,还是用enabled: false配合refetch。
3.9 类型性能小贴士
项目大了,TanStack 的类型推导可能让 TS 检查变慢。几个优化方向:
- 别在不必要的地方让 TS 推导大类型。比如路由 loader 里调
ensureQueryData,如果返回值没人用,用async包一层让它返回Promise<void>,别让 loader 的返回类型进路由树。 - 路由 API 传
from收窄范围。<Link to=".." />这种会让 TS 检查所有路由的 search params,传from={Route.fullPath}能收窄到当前路由分支。 - 别直接用
LinkProps这种大类型。用as const satisfies LinkProps代替const props: LinkProps,让类型更精确。
3.10 小结
TanStack 的类型安全靠三招:自动推导让你少写类型,queryOptions/Register 让类型穿透到全局,状态收窄让运行时判断和类型判断同步。
记住一个原则:能推导就别手写泛型。如果你发现自己在 useQuery<TData, TError> 这种写法里挣扎,先想想是不是 queryFn 的返回类型没标清楚,或者错误类型该用 Register 全局注册。
下一篇正式进入 Query,从安装和 QueryClient 开始。