mcpField
T
type-safety

type-safety

github.com/tanstack開発15.3k209 回インストール4 日前に更新
リポジトリ

type-safety について

TanStack Router を使う開発者向けのスキルです。ルート型の推論と、不要な注釈や広い型によるエラー・TypeScript の性能問題を避ける方法を説明します。

主な機能

  1. 01ルートデータとパラメーターの型推論
  2. 02ルートフックでの型の絞り込み
  3. 03分割ファイルでの型付きフック利用
  4. 04大規模なルートツリーの TypeScript 性能指針
  5. 05セレクターと構造共有の最適化

活用シーン

  1. 01型安全なルートと検索パラメーターの設定
  2. 02分割ルートコンポーネントから型付きフックを利用
  3. 03ルーターコードの型アサーションや性能問題の調査

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/tanstack/router --skill type-safety

インストール後、Claude Code で/type-safetyを入力して読み込まれたか確認します。

詳細

型安全性

TanStack Router は完全に型推論されます。パラメーター、検索パラメーター、コンテキスト、ローダーのデータは、すべて自動的にルートツリー全体に引き継がれます。AI エージェントが最もよく犯す間違いは、すでに推論されている値に型注釈、キャスト、ジェネリックパラメーターを追加することです。

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

複数のルートでコンポーネントを使う場合は、from の代わりに strict: false を使います。

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

遅延読み込みチャンクにルート設定を取り込まないようにするには、Route をインポートする代わりに getRouteApi を使います。

// 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,
})
Link オプションオブジェクトには 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'>
型安全な Link オプション配列
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 — カスタムコンポーネントで型安全な Link Props を使う
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 の設定)、router-core/navigation(from の絞り込み)、router-core/code-splitting(getRouteApi)。

コメント