关于 type-safety
面向使用 TanStack Router 的开发者,说明如何依靠路由树推导类型,并避免注解、类型断言及未收窄类型带来的错误和性能问题。
主要功能
- 01路由参数与数据的类型推导
- 02路由钩子的类型收窄
- 03代码拆分文件中的类型访问
- 04大型路由树的 TypeScript 性能建议
- 05选择器与结构共享优化
适用场景
- 01为路由和搜索参数配置类型安全
- 02在拆分的路由组件中访问类型化钩子
- 03排查路由代码中的类型断言和性能问题
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ tanstack/ router --skill type-safety安装后在 Claude Code 中输入/type-safety确认已加载。
详细介绍
类型安全
TanStack Router 具有完整的类型推断能力。参数、搜索参数、上下文和加载器数据都会自动贯穿路由树。AI Agent 最常见的错误,就是为已经推断出的值添加类型注解、类型断言或泛型参数。
CRITICAL:绝不要对推断出的值使用
as Type、显式泛型参数、satisfies(用于钩子返回值)或类型注解。每次类型断言都会掩盖真正的类型错误,并破坏推断链。 CRITICAL:不要把 TanStack Router 和 Next.js 或 React Router 混淆。这里没有getServerSideProps,没有useSearchParams(),也没有来自react-router-dom的useLoaderData()。
唯一必需的类型注解:Register
如果没有此项,Link、useNavigate、useSearch 等顶层导出的类型就不安全。
// src/router.tsx
import { createRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
const router = createRouter({ routeTree })
// THIS IS REQUIRED — the single type registration for the entire app
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}
export default router
完成注册后,整个应用中的每个 Link、useNavigate、useSearch、useParams 都具有完整类型。
类型会自动流转
路由钩子——无需注解
// src/routes/posts.$postId.tsx
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/posts/$postId')({
validateSearch: (search: Record<string, unknown>) => ({
page: Number(search.page ?? 1),
}),
loader: async ({ params }) => {
// params.postId is already typed as string — do not annotate
const post = await fetchPost(params.postId)
return { post }
},
component: PostComponent,
})
function PostComponent() {
// ALL of these are fully inferred — do NOT add type annotations
const { postId } = Route.useParams()
// ^? string
const { page } = Route.useSearch()
// ^? number
const { post } = Route.useLoaderData()
// ^? { id: string; title: string; body: string }
return (
<div>
<h1>{post.title}</h1>
<p>Page {page}</p>
</div>
)
}
上下文会贯穿整个树
// src/routes/__root.tsx
import { createRootRouteWithContext, Outlet } from '@tanstack/react-router'
interface RouterContext {
auth: { userId: string; role: 'admin' | 'user' } | null
}
// Note: createRootRouteWithContext is a FACTORY — call it TWICE: ()()
export const Route = createRootRouteWithContext<RouterContext>()({
component: () => <Outlet />,
})
// src/routes/dashboard.tsx
import { createFileRoute, redirect } from '@tanstack/react-router'
export const Route = createFileRoute('/dashboard')({
beforeLoad: ({ context }) => {
// context.auth is already typed as { userId: string; role: 'admin' | 'user' } | null
// NO annotation needed
if (!context.auth) throw redirect({ to: '/login' })
return { user: context.auth }
},
loader: ({ context }) => {
// context.user is typed as { userId: string; role: 'admin' | 'user' }
// This was added by beforeLoad above — fully inferred
return fetchDashboard(context.user.userId)
},
component: DashboardComponent,
})
function DashboardComponent() {
const data = Route.useLoaderData()
const { user } = Route.useRouteContext()
return <h1>Welcome {user.userId}</h1>
}
使用 from 缩小类型范围
如果不使用 from,钩子会返回所有路由类型的联合类型,这会拖慢 TypeScript,并降低精确度。
用于钩子
import { useSearch, useParams, useNavigate } from '@tanstack/react-router'
function PostSidebar() {
// WRONG — search is a union of ALL routes' search params
const search = useSearch()
// CORRECT — search is narrowed to /posts/$postId's search params
const search = useSearch({ from: '/posts/$postId' })
// ^? { page: number }
// CORRECT — params narrowed to this route
const { postId } = useParams({ from: '/posts/$postId' })
// CORRECT — navigate narrowed for relative paths
const navigate = useNavigate({ from: '/posts/$postId' })
}
用于 Link
import { Link } from '@tanstack/react-router'
// WRONG — search resolves to union of ALL routes' search params, slow TS check
<Link to=".." search={{ page: 0 }} />
// CORRECT — narrowed, fast TS check
<Link from="/posts/$postId" to=".." search={{ page: 0 }} />
// Also correct — Route.fullPath in route components
<Link from={Route.fullPath} to=".." search={{ page: 0 }} />
共享组件:strict: false
当一个组件用于多个路由时,使用 strict: false,而不是 from:
import { useSearch } from '@tanstack/react-router'
function GlobalSearch() {
// Returns union of all routes' search params — no runtime error if route doesn't match
const search = useSearch({ strict: false })
return <span>Query: {search.q ?? ''}</span>
}
代码拆分文件:getRouteApi
使用 getRouteApi,而不是导入 Route,以避免将路由配置带入惰性加载代码块:
// src/routes/posts.lazy.tsx
import { createLazyFileRoute, getRouteApi } from '@tanstack/react-router'
const routeApi = getRouteApi('/posts')
export const Route = createLazyFileRoute('/posts')({
component: PostsComponent,
})
function PostsComponent() {
const data = routeApi.useLoaderData()
const { page } = routeApi.useSearch()
return <div>Page {page}</div>
}
TypeScript 性能
在大型路由树中对 addChildren 使用对象语法
// SLOWER — tuple syntax
const routeTree = rootRoute.addChildren([
postsRoute.addChildren([postRoute, postsIndexRoute]),
indexRoute,
])
// FASTER — object syntax (TS checks objects faster than large tuples)
const routeTree = rootRoute.addChildren({
postsRoute: postsRoute.addChildren({ postRoute, postsIndexRoute }),
indexRoute,
})
使用基于文件的路由时,路由树会自动生成,因此这部分会为你处理。
避免从加载器返回未使用的推断类型
使用 TanStack Query 等外部缓存时,不要让路由器推断你不会使用的复杂返回类型:
// SLOWER — TS infers the full query return type into the route tree
export const Route = createFileRoute('/posts/$postId')({
loader: ({ context: { queryClient }, params: { postId } }) =>
queryClient.query({ ...postQueryOptions(postId), staleTime: 'static' }),
component: PostComponent,
})
// FASTER — async loader returns no query data, inference stays out of the route tree
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ context: { queryClient }, params: { postId: id } }) => {
await queryClient.query({ ...postQueryOptions(id), staleTime: 'static' })
},
component: PostComponent,
})
链接选项对象使用 as const satisfies
绝不要将 LinkProps 用作变量类型——它是一个极其庞大的联合类型:
import type { LinkProps, RegisteredRouter } from '@tanstack/react-router'
// WRONG — LinkProps is a massive union, extremely slow TS check
const wrongProps: LinkProps = { to: '/posts' }
// CORRECT — infer a precise type, validate against LinkProps
const goodProps = { to: '/posts' } as const satisfies LinkProps
// EVEN BETTER — narrow LinkProps with generic params
const narrowedProps = {
to: '/posts',
} as const satisfies LinkProps<RegisteredRouter, string, '/posts'>
类型安全的链接选项数组
import type { LinkProps } from '@tanstack/react-router'
export const navLinks = [
{ to: '/posts' },
{ to: '/posts/$postId', params: { postId: '1' } },
] as const satisfies ReadonlyArray<LinkProps>
// Use the precise inferred type, not LinkProps directly
export type NavLink = (typeof navLinks)[number]
泛型组件的类型工具
ValidateLinkOptions——自定义组件中的类型安全链接属性
import {
Link,
type RegisteredRouter,
type ValidateLinkOptions,
} from '@tanstack/react-router'
interface NavItemProps<
TRouter extends RegisteredRouter = RegisteredRouter,
TOptions = unknown,
> {
label: string
linkOptions: ValidateLinkOptions<TRouter, TOptions>
}
export function NavItem<TRouter extends RegisteredRouter, TOptions>(
props: NavItemProps<TRouter, TOptions>,
): React.ReactNode
export function NavItem(props: NavItemProps): React.ReactNode {
return (
<li>
<Link {...props.linkOptions}>{props.label}</Link>
</li>
)
}
// Usage — fully type-safe
<NavItem label="Posts" linkOptions={{ to: '/posts' }} />
<NavItem label="Post" linkOptions={{ to: '/posts/$postId', params: { postId: '1' } }} />
ValidateNavigateOptions 和 ValidateRedirectOptions
与上面的 ValidateLinkOptions 模式相同,适用于 useNavigate 和 redirect。声明一个泛型公共重载和一个非泛型实现签名,这样调用位置可以保持类型收窄,而函数体无需类型断言即可正常工作:
import {
useNavigate,
type RegisteredRouter,
type ValidateNavigateOptions,
} from '@tanstack/react-router'
export function useDelayedNavigate<
TRouter extends RegisteredRouter = RegisteredRouter,
TOptions = unknown,
>(
options: ValidateNavigateOptions<TRouter, TOptions>,
delayMs: number,
): () => void
export function useDelayedNavigate(
options: ValidateNavigateOptions,
delayMs: number,
) {
const navigate = useNavigate()
return () => {
setTimeout(() => navigate(options), delayMs)
}
}
ValidateRedirectOptions 的用法完全相同——声明一个接受 ValidateRedirectOptions<TRouter, TOptions> 的泛型重载和一个接受 ValidateRedirectOptions 的实现签名,然后在函数体中调用 redirect(options)。
使用渲染属性以获得最佳性能
不要接收 LinkProps,而是反转控制流,让调用位置的 Link 类型得到收窄:
function Card(props: { title: string; renderLink: () => React.ReactNode }) {
return (
<div>
<h2>{props.title}</h2>
{props.renderLink()}
</div>
)
}
// Link narrowed to exactly /posts — no union check
;<Card title="All Posts" renderLink={() => <Link to="/posts">View</Link>} />
渲染优化
使用 select 实现细粒度选择器
function PostTitle() {
// Only re-renders when page changes, not when other search params change
const page = Route.useSearch({ select: ({ page }) => page })
return <span>Page {page}</span>
}
结构共享
保留搜索参数在重新渲染期间的引用标识:
const router = createRouter({
routeTree,
defaultStructuralSharing: true, // Enable globally
})
// Or per-hook
const result = Route.useSearch({
select: (search) => ({ foo: search.foo, label: `Page ${search.foo}` }),
structuralSharing: true,
})
结构共享仅适用于兼容 JSON 的数据。如果启用 structuralSharing: true 后返回类实例,TypeScript 会报错。
常见错误
1. CRITICAL:为推断出的值添加类型注解或类型断言
// WRONG — casting masks real type errors
const search = useSearch({ from: '/posts' }) as { page: number }
// WRONG — unnecessary annotation
const params: { postId: string } = useParams({ from: '/posts/$postId' })
// WRONG — generic param on hook
const data = useLoaderData<{ posts: Post[] }>({ from: '/posts' })
// CORRECT — let inference work
const search = useSearch({ from: '/posts' })
const params = useParams({ from: '/posts/$postId' })
const data = useLoaderData({ from: '/posts' })
2. HIGH:使用未收窄的 LinkProps 类型
// WRONG — LinkProps is a massive union, causes severe TS slowdown
const myProps: LinkProps = { to: '/posts' }
// CORRECT — use as const satisfies for precise inference
const myProps = { to: '/posts' } as const satisfies LinkProps
3. HIGH:未使用 from 收窄 Link/useNavigate
// WRONG — search is a union of ALL routes, TS check grows with route count
<Link to=".." search={{ page: 0 }} />
// CORRECT — narrowed, fast check
<Link from={Route.fullPath} to=".." search={{ page: 0 }} />
4. CRITICAL(跨技能):缺少路由器类型注册
// WRONG — Link/useNavigate have no autocomplete, all paths are untyped strings
const router = createRouter({ routeTree })
// (no declare module)
// CORRECT — always register
const router = createRouter({ routeTree })
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}
5. CRITICAL(跨技能):错误的框架导入和文件结构
错误框架的代码看似合理(因为它也是 React),但会导致构建失败,或在运行时产生冲突的 / 路由。
// WRONG — react-router-dom and next/* are different libraries
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
import Link from 'next/link'
import { useRouter, useParams } from 'next/navigation'
// CORRECT — all routing exports come from @tanstack/react-router
import {
Link,
Outlet,
useNavigate,
useRouter,
useLocation,
useParams,
redirect,
} from '@tanstack/react-router'
// WRONG file structures + APIs:
// src/pages/*.tsx with getServerSideProps / getStaticProps (Next.js Pages Router)
// app/layout.tsx + app/page.tsx (Next.js App Router)
// _app/index.tsx, pages/_app.tsx, pages/_document.tsx (Next.js custom App)
// loader/action exports (Remix)
// CORRECT — TanStack file-based routing at src/routes/*.tsx
export const Route = createFileRoute('/posts')({
loader: async () => { ... },
validateSearch: zodValidator(schema),
component: PostsComponent,
})
const search = Route.useSearch()
如果构建错误提到 react-router-dom、next/、pages/_app 或重复的 / 路由,请修复导入,不要用类型断言来掩盖问题。
6. 重要:不要将类型检查视为运行时架构传播的证明
类型可以表明某个字段存在,但数据库投影、API 序列化器或服务端函数可能会将其省略。添加或重命名字段时,请追踪该值在存储、验证、处理器输出、加载器数据和渲染 UI 中的传递过程。不要将响应强制转换为所需类型。
针对真实处理器或序列化后的响应添加运行时断言,例如 expect(await getOrder({ data: { id } })).toMatchObject({ totalCents: 2599 })。
然后运行路由级测试和生产构建。类型测试仍然必不可少,但它不能替代运行时契约测试。
另请参阅:router-core(Register setup)、router-core/navigation(from narrowing)、router-core/code-splitting(getRouteApi)。