Repository: TanStack/router
Pull request #1664 (2024-05-24 15:16:32)

fix(react-router): merge params, context, search and loaderData with `strict: false`
- `useRouteContext`, `useParams`, `useSearch`, `useLoaderData` will merge objects when `strict: false`. Conflicting properties are resolved as a union
- Mapped type is used instead of intersections
- Removed experimental flag for `experimental_returnIntersections`. We no longer use intersections for this which improves the TS performance as we handle the conflicts ourselves

nx-cloud[bot]: # ☁️ Nx Cloud Report

CI is running for commit 287bb5675119c889f061d6b8f6cb298022745f6e.

[📂 Click to track the progress, see the status, the terminal output, and the build insights.](https://cloud.nx.app/cipes/6650af6c3c259964f7dbc63f?utm_source=pull-request&utm_medium=comment)


---

Sent with 💌 from [NxCloud.](https://nx.app?utm_source=pull-request&utm_medium=comment)
schiller-manuel: fixes #1659
fixes #1217 

Patch:
diff --git a/packages/react-router/src/Matches.tsx b/packages/react-router/src/Matches.tsx
index 5c03f31dc..ea6ad575f 100644
--- a/packages/react-router/src/Matches.tsx
+++ b/packages/react-router/src/Matches.tsx
@@ -17,6 +17,8 @@ import {
 import { rootRouteId } from './root'
 import type { ResolveRelativePath, ToOptions } from './link'
 import type {
+  AllContext,
+  AllLoaderData,
   AllParams,
   FullSearchSchema,
   ParseRoute,
@@ -72,16 +74,20 @@ export interface RouteMatch<
 export type MakeRouteMatch<
   TRouteTree extends AnyRoute = RegisteredRouter['routeTree'],
   TRouteId = ParseRoute<TRouteTree>['id'],
-  TReturnIntersection extends boolean = false,
+  TStrict extends boolean = true,
   TTypes extends AnyRoute['types'] = RouteById<TRouteTree, TRouteId>['types'],
-  TAllParams = TReturnIntersection extends false
-    ? TTypes['allParams']
-    : Partial<AllParams<TRouteTree>>,
-  TFullSearchSchema = TReturnIntersection extends false
-    ? TTypes['fullSearchSchema']
-    : Partial<FullSearchSchema<TRouteTree>>,
-  TLoaderData = TTypes['loaderData'],
-  TAllContext = TTypes['allContext'],
+  TAllParams = TStrict extends false
+    ? AllParams<TRouteTree>
+    : TTypes['allParams'],
+  TFullSearchSchema = TStrict extends false
+    ? FullSearchSchema<TRouteTree>
+    : TTypes['fullSearchSchema'],
+  TLoaderData = TStrict extends false
+    ? AllLoaderData<TRouteTree>
+    : TTypes['loaderData'],
+  TAllContext = TStrict extends false
+    ? AllContext<TRouteTree>
+    : TTypes['allContext'],
   TRouteContext = TTypes['routeContext'],
   TLoaderDeps = TTypes['loaderDeps'],
 > = RouteMatch<
@@ -549,13 +555,9 @@ export function MatchRoute<
 export function useMatches<
   TRouteTree extends AnyRoute = RegisteredRouter['routeTree'],
   TRouteId extends RouteIds<TRouteTree> = ParseRoute<TRouteTree>['id'],
-  TReturnIntersection extends boolean = false,
-  TRouteMatch = MakeRouteMatch<TRouteTree, TRouteId, TReturnIntersection>,
+  TRouteMatch = MakeRouteMatch<TRouteTree, TRouteId>,
   T = Array<TRouteMatch>,
->(opts?: {
-  select?: (matches: Array<TRouteMatch>) => T
-  experimental_returnIntersection?: TReturnIntersection
-}): T {
+>(opts?: { select?: (matches: Array<TRouteMatch>) => T }): T {
   return useRouterState({
     select: (state) => {
       const matches = state.matches
@@ -569,13 +571,9 @@ export function useMatches<
 export function useParentMatches<
   TRouteTree extends AnyRoute = RegisteredRouter['routeTree'],
   TRouteId extends RouteIds<TRouteTree> = ParseRoute<TRouteTree>['id'],
-  TReturnIntersection extends boolean = false,
-  TRouteMatch = MakeRouteMatch<TRouteTree, TRouteId, TReturnIntersection>,
+  TRouteMatch = MakeRouteMatch<TRouteTree, TRouteId>,
   T = Array<TRouteMatch>,
->(opts?: {
-  select?: (matches: Array<TRouteMatch>) => T
-  experimental_returnIntersection?: TReturnIntersection
-}): T {
+>(opts?: { select?: (matches: Array<TRouteMatch>) => T }): T {
   const contextMatchId = React.useContext(matchContext)
 
   return useMatches({
@@ -594,13 +592,9 @@ export function useParentMatches<
 export function useChildMatches<
   TRouteTree extends AnyRoute = RegisteredRouter['routeTree'],
   TRouteId extends RouteIds<TRouteTree> = ParseRoute<TRouteTree>['id'],
-  TReturnIntersection extends boolean = false,
-  TRouteMatch = MakeRouteMatch<TRouteTree, TRouteId, TReturnIntersection>,
+  TRouteMatch = MakeRouteMatch<TRouteTree, TRouteId>,
   T = Array<TRouteMatch>,
->(opts?: {
-  select?: (matches: Array<TRouteMatch>) => T
-  experimental_returnIntersection?: TReturnIntersection
-}): T {
+>(opts?: { select?: (matches: Array<TRouteMatch>) => T }): T {
   const contextMatchId = React.useContext(matchContext)
 
   return useMatches({
diff --git a/packages/react-router/src/fileRoute.ts b/packages/react-router/src/fileRoute.ts
index 2db7a76e7..7359458e6 100644
--- a/packages/react-router/src/fileRoute.ts
+++ b/packages/react-router/src/fileRoute.ts
@@ -94,7 +94,7 @@ export class FileRoute<
     TAllContext = ResolveAllContext<TParentRoute, TRouteContext>,
     TRouterContext = AnyContext,
     TLoaderDeps extends Record<string, any> = {},
-    TLoaderDataReturn = unknown,
+    TLoaderDataReturn = {},
     TLoaderData = ResolveLoaderData<TLoaderDataReturn>,
     TChildren = unknown,
   >(
diff --git a/packages/react-router/src/route.ts b/packages/react-router/src/route.ts
index 4a500713b..8c918d09d 100644
--- a/packages/react-router/src/route.ts
+++ b/packages/react-router/src/route.ts
@@ -71,10 +71,8 @@ export type RouteOptions<
   TRouterContext = AnyContext,
   TAllContext = AnyContext,
   TLoaderDeps extends Record<string, any> = {},
-  TLoaderDataReturn = unknown,
-  TLoaderData = [TLoaderDataReturn] extends [never]
-    ? undefined
-    : TLoaderDataReturn,
+  TLoaderDataReturn = {},
+  TLoaderData = ResolveLoaderData<TLoaderDataReturn>,
 > = BaseRouteOptions<
   TParentRoute,
   TCustomId,
@@ -121,7 +119,7 @@ export type FileBaseRouteOptions<
   TRouterContext = AnyContext,
   TAllContext = AnyContext,
   TLoaderDeps extends Record<string, any> = {},
-  TLoaderDataReturn = unknown,
+  TLoaderDataReturn = {},
 > = {
   validateSearch?: SearchSchemaValidator<TSearchSchemaInput, TSearchSchema>
   shouldReload?:
@@ -187,7 +185,7 @@ export type BaseRouteOptions<
   TRouterContext = AnyContext,
   TAllContext = AnyContext,
   TLoaderDeps extends Record<string, any> = {},
-  TLoaderDataReturn = unknown,
+  TLoaderDataReturn = {},
 > = RoutePathOptions<TCustomId, TPath> &
   FileBaseRouteOptions<
     TParentRoute,
@@ -344,7 +342,7 @@ export type RouteLoaderFn<
   in out TLoaderDeps extends Record<string, any> = {},
   in out TAllContext = AnyContext,
   in out TRouteContext = AnyContext,
-  TLoaderData = unknown,
+  TLoaderData = undefined,
 > = (
   match: LoaderFnContext<TAllParams, TLoaderDeps, TAllContext, TRouteContext>,
 ) => Promise<TLoaderData> | TLoaderData
@@ -629,7 +627,7 @@ export class Route<
   >,
   in out TRouterContext = AnyContext,
   in out TLoaderDeps extends Record<string, any> = {},
-  TLoaderDataReturn = unknown,
+  TLoaderDataReturn = {},
   in out TLoaderData = ResolveLoaderData<TLoaderDataReturn>,
   in out TChildren = unknown,
 > {
@@ -971,7 +969,7 @@ export function createRoute<
   >,
   TRouterContext = AnyContext,
   TLoaderDeps extends Record<string, any> = {},
-  TLoaderDataReturn = unknown,
+  TLoaderDataReturn = {},
   TLoaderData = ResolveLoaderData<TLoaderDataReturn>,
   TChildren = unknown,
 >(
@@ -1029,7 +1027,7 @@ export type RootRouteOptions<
   TRouteContext = ResolveRouteContext<TRouteContextReturn>,
   TRouterContext = {},
   TLoaderDeps extends Record<string, any> = {},
-  TLoaderDataReturn = unknown,
+  TLoaderDataReturn = {},
   TLoaderData = ResolveLoaderData<TLoaderDataReturn>,
 > = Omit<
   RouteOptions<
@@ -1069,10 +1067,8 @@ export function createRootRouteWithContext<TRouterContext extends {}>() {
       ? RouteContext
       : TRouteContextReturn,
     TLoaderDeps extends Record<string, any> = {},
-    TLoaderDataReturn = unknown,
-    TLoaderData = [TLoaderDataReturn] extends [never]
-      ? undefined
-      : TLoaderDataReturn,
+    TLoaderDataReturn = {},
+    TLoaderData = ResolveLoaderData<TLoaderDataReturn>,
   >(
     options?: RootRouteOptions<
       TSearchSchemaInput,
@@ -1116,7 +1112,7 @@ export class RootRoute<
   in out TRouteContext = ResolveRouteContext<TRouteContextReturn>,
   in out TRouterContext = {},
   TLoaderDeps extends Record<string, any> = {},
-  TLoaderDataReturn = unknown,
+  TLoaderDataReturn = {},
   in out TLoaderData = ResolveLoaderData<TLoaderDataReturn>,
 > extends Route<
   any, // TParentRoute
@@ -1168,7 +1164,7 @@ export function createRootRoute<
   TRouteContext = ResolveRouteContext<TRouteContextReturn>,
   TRouterContext = {},
   TLoaderDeps extends Record<string, any> = {},
-  TLoaderDataReturn = unknown,
+  TLoaderDataReturn = {},
   TLoaderData = ResolveLoaderData<TLoaderDataReturn>,
 >(
   options?: Omit<
@@ -1326,7 +1322,7 @@ export class NotFoundRoute<
   >,
   TRouterContext = AnyContext,
   TLoaderDeps extends Record<string, any> = {},
-  TLoaderDataReturn = unknown,
+  TLoaderDataReturn = {},
   TLoaderData = ResolveLoaderData<TLoaderDataReturn>,
   TChildren = unknown,
 > extends Route<
diff --git a/packages/react-router/src/routeInfo.ts b/packages/react-router/src/routeInfo.ts
index f4e9d6ba5..3b33d097a 100644
--- a/packages/react-router/src/routeInfo.ts
+++ b/packages/react-router/src/routeInfo.ts
@@ -1,6 +1,6 @@
-import type { AnyRoute } from './route'
-import type { AnyRouter, Router, TrailingSlashOption } from './router'
-import type { UnionToIntersection, UnionToTuple } from './utils'
+import type { AnyRoute, RootSearchSchema } from './route'
+import type { AnyRouter, TrailingSlashOption } from './router'
+import type { Expand, MergeUnion } from './utils'
 
 export type ParseRoute<TRouteTree, TAcc = TRouteTree> = TRouteTree extends {
   types: { children: infer TChildren }
@@ -114,26 +114,20 @@ export type RouteByToPath<TRouter extends AnyRouter, TTo> = Extract<
   AnyRoute
 >
 
-// eslint-disable-next-line @typescript-eslint/naming-convention
-type UnionizeCollisions<T, U> = {
-  [P in keyof T & keyof U]: T[P] extends U[P] ? T[P] : T[P] | U[P]
-}
-// eslint-disable-next-line @typescript-eslint/naming-convention
-type Reducer<T, U, C = UnionizeCollisions<T, U>> = C &
-  Omit<T, keyof C> &
-  Omit<U, keyof C>
-
-type Reduce<TValue extends Array<any>, TResult = unknown> = TValue extends [
-  infer First,
-  ...infer Rest,
-]
-  ? Reduce<Rest, Reducer<TResult, First>>
-  : TResult
-
-export type FullSearchSchema<TRouteTree extends AnyRoute> = Partial<
-  Reduce<UnionToTuple<ParseRoute<TRouteTree>['types']['fullSearchSchema']>>
+export type FullSearchSchema<TRouteTree extends AnyRoute> = Expand<
+  MergeUnion<
+    Exclude<ParseRoute<TRouteTree>['types']['searchSchema'], RootSearchSchema>
+  >
+>
+
+export type AllParams<TRouteTree extends AnyRoute> = Expand<
+  MergeUnion<ParseRoute<TRouteTree>['types']['params']>
+>
+
+export type AllContext<TRouteTree extends AnyRoute> = Expand<
+  MergeUnion<ParseRoute<TRouteTree>['types']['allContext']>
 >
 
-export type AllParams<TRouteTree extends AnyRoute> = UnionToIntersection<
-  ParseRoute<TRouteTree>['types']['allParams']
+export type AllLoaderData<TRouteTree extends AnyRoute> = Expand<
+  MergeUnion<ParseRoute<TRouteTree>['types']['loaderData']>
 >
diff --git a/packages/react-router/src/router.ts b/packages/react-router/src/router.ts
index 3070f0e6c..2d1abad62 100644
--- a/packages/react-router/src/router.ts
+++ b/packages/react-router/src/router.ts
@@ -509,7 +509,7 @@ export class Router<
     'stringifySearch' | 'parseSearch' | 'context'
   >
   history!: RouterHistory
-  latestLocation!: ParsedLocation
+  latestLocation!: ParsedLocation<FullSearchSchema<TRouteTree>>
   basepath!: string
   routeTree!: TRouteTree
   routesById!: RoutesById<TRouteTree>
@@ -770,7 +770,7 @@ export class Router<
   }
 
   parseLocation = (
-    previousLocation?: ParsedLocation,
+    previousLocation?: ParsedLocation<FullSearchSchema<TRouteTree>>,
   ): ParsedLocation<FullSearchSchema<TRouteTree>> => {
     const parse = ({
       pathname,
diff --git a/packages/react-router/src/useLoaderData.tsx b/packages/react-router/src/useLoaderData.tsx
index 56aa836ef..d2fbd8752 100644
--- a/packages/react-router/src/useLoaderData.tsx
+++ b/packages/react-router/src/useLoaderData.tsx
@@ -5,25 +5,34 @@ import type { MakeRouteMatch } from './Matches'
 import type { RouteIds } from './routeInfo'
 import type { StrictOrFrom } from './utils'
 
+export type UseLoaderDataOptions<
+  TFrom,
+  TStrict extends boolean,
+  TRouteMatch,
+  TSelected,
+> = StrictOrFrom<TFrom, TStrict> & {
+  select?: (match: TRouteMatch) => TSelected
+}
+
 export function useLoaderData<
   TRouteTree extends AnyRoute = RegisteredRouter['routeTree'],
   TFrom extends RouteIds<TRouteTree> = RouteIds<TRouteTree>,
-  TRouteMatch extends MakeRouteMatch<TRouteTree, TFrom> = MakeRouteMatch<
+  TStrict extends boolean = true,
+  TRouteMatch extends MakeRouteMatch<
     TRouteTree,
-    TFrom
-  >,
+    TFrom,
+    TStrict
+  > = MakeRouteMatch<TRouteTree, TFrom, TStrict>,
   TSelected = Required<TRouteMatch>['loaderData'],
 >(
-  opts: StrictOrFrom<TFrom> & {
-    select?: (match: TRouteMatch) => TSelected
-  },
+  opts: UseLoaderDataOptions<TFrom, TStrict, TRouteMatch, TSelected>,
 ): TSelected {
-  return useMatch({
+  return useMatch<TRouteTree, TFrom, TStrict, TRouteMatch, TSelected>({
     ...opts,
     select: (s) => {
       return typeof opts.select === 'function'
         ? opts.select(s.loaderData as TRouteMatch)
-        : s.loaderData
+        : (s.loaderData as TSelected)
     },
-  }) as TSelected
+  })
 }
diff --git a/packages/react-router/src/useMatch.tsx b/packages/react-router/src/useMatch.tsx
index 563f45b13..75568df35 100644
--- a/packages/react-router/src/useMatch.tsx
+++ b/packages/react-router/src/useMatch.tsx
@@ -8,17 +8,22 @@ import type { MakeRouteMatch } from './Matches'
 import type { RouteIds } from './routeInfo'
 import type { StrictOrFrom } from './utils'
 
+export type UseMatchOptions<
+  TFrom,
+  TStrict extends boolean,
+  TRouteMatch,
+  TSelected,
+> = StrictOrFrom<TFrom, TStrict> & {
+  select?: (match: TRouteMatch) => TSelected
+}
+
 export function useMatch<
   TRouteTree extends AnyRoute = RegisteredRouter['routeTree'],
   TFrom extends RouteIds<TRouteTree> = RouteIds<TRouteTree>,
-  TReturnIntersection extends boolean = false,
-  TRouteMatch = MakeRouteMatch<TRouteTree, TFrom, TReturnIntersection>,
+  TStrict extends boolean = true,
+  TRouteMatch = MakeRouteMatch<TRouteTree, TFrom, TStrict>,
   TSelected = TRouteMatch,
->(
-  opts: StrictOrFrom<TFrom, TReturnIntersection> & {
-    select?: (match: TRouteMatch) => TSelected
-  },
-): TSelected {
+>(opts: UseMatchOptions<TFrom, TStrict, TRouteMatch, TSelected>): TSelected {
   const nearestMatchId = React.useContext(matchContext)
 
   const matchSelection = useRouterState({
diff --git a/packages/react-router/src/useParams.tsx b/packages/react-router/src/useParams.tsx
index e0b82f931..3c2913377 100644
--- a/packages/react-router/src/useParams.tsx
+++ b/packages/react-router/src/useParams.tsx
@@ -2,22 +2,26 @@ import { useMatch } from './useMatch'
 import type { AnyRoute } from './route'
 import type { AllParams, RouteById, RouteIds } from './routeInfo'
 import type { RegisteredRouter } from './router'
-import type { Expand } from './utils'
 import type { StrictOrFrom } from './utils'
 
+export type UseParamsOptions<
+  TFrom,
+  TStrict extends boolean,
+  TParams,
+  TSelected,
+> = StrictOrFrom<TFrom, TStrict> & {
+  select?: (params: TParams) => TSelected
+}
+
 export function useParams<
   TRouteTree extends AnyRoute = RegisteredRouter['routeTree'],
   TFrom extends RouteIds<TRouteTree> = RouteIds<TRouteTree>,
-  TReturnIntersection extends boolean = false,
-  TParams = TReturnIntersection extends false
-    ? RouteById<TRouteTree, TFrom>['types']['allParams']
-    : Expand<Partial<AllParams<TRouteTree>>>,
+  TStrict extends boolean = true,
+  TParams = TStrict extends false
+    ? AllParams<TRouteTree>
+    : RouteById<TRouteTree, TFrom>['types']['allParams'],
   TSelected = TParams,
->(
-  opts: StrictOrFrom<TFrom, TReturnIntersection> & {
-    select?: (params: TParams) => TSelected
-  },
-): TSelected {
+>(opts: UseParamsOptions<TFrom, TStrict, TParams, TSelected>): TSelected {
   return useMatch({
     ...opts,
     select: (match) => {
diff --git a/packages/react-router/src/useRouteContext.ts b/packages/react-router/src/useRouteContext.ts
index 81a490600..772f1513e 100644
--- a/packages/react-router/src/useRouteContext.ts
+++ b/packages/react-router/src/useRouteContext.ts
@@ -1,19 +1,29 @@
 import { useMatch } from './useMatch'
 import type { MakeRouteMatch } from './Matches'
 import type { AnyRoute } from './route'
-import type { RouteById, RouteIds } from './routeInfo'
+import type { AllContext, RouteById, RouteIds } from './routeInfo'
 import type { RegisteredRouter } from './router'
-import type { StrictOrFrom } from './utils'
+import type { Expand, StrictOrFrom } from './utils'
+
+export type UseRouteContextOptions<
+  TFrom,
+  TStrict extends boolean,
+  TRouteContext,
+  TSelected,
+> = StrictOrFrom<TFrom, TStrict> & {
+  select?: (search: TRouteContext) => TSelected
+}
 
 export function useRouteContext<
   TRouteTree extends AnyRoute = RegisteredRouter['routeTree'],
   TFrom extends RouteIds<TRouteTree> = RouteIds<TRouteTree>,
-  TRouteContext = RouteById<TRouteTree, TFrom>['types']['allContext'],
+  TStrict extends boolean = true,
+  TRouteContext = TStrict extends false
+    ? AllContext<TRouteTree>
+    : Expand<RouteById<TRouteTree, TFrom>['types']['allContext']>,
   TSelected = TRouteContext,
 >(
-  opts: StrictOrFrom<TFrom> & {
-    select?: (search: TRouteContext) => TSelected
-  },
+  opts: UseRouteContextOptions<TFrom, TStrict, TRouteContext, TSelected>,
 ): TSelected {
   return useMatch({
     ...(opts as any),
diff --git a/packages/react-router/src/useSearch.tsx b/packages/react-router/src/useSearch.tsx
index 5c15706b5..4d2fc778c 100644
--- a/packages/react-router/src/useSearch.tsx
+++ b/packages/react-router/src/useSearch.tsx
@@ -5,22 +5,27 @@ import type { RegisteredRouter } from './router'
 import type { MakeRouteMatch } from './Matches'
 import type { StrictOrFrom } from './utils'
 
+export type UseSearchOptions<
+  TFrom,
+  TStrict extends boolean,
+  TSearch,
+  TSelected,
+> = StrictOrFrom<TFrom, TStrict> & {
+  select?: (search: TSearch) => TSelected
+}
+
 export function useSearch<
   TRouteTree extends AnyRoute = RegisteredRouter['routeTree'],
   TFrom extends RouteIds<TRouteTree> = RouteIds<TRouteTree>,
-  TReturnIntersection extends boolean = false,
-  TSearch = TReturnIntersection extends false
-    ? Exclude<
+  TStrict extends boolean = true,
+  TSearch = TStrict extends false
+    ? FullSearchSchema<TRouteTree>
+    : Exclude<
         RouteById<TRouteTree, TFrom>['types']['fullSearchSchema'],
         RootSearchSchema
-      >
-    : Partial<Omit<FullSearchSchema<TRouteTree>, keyof RootSearchSchema>>,
+      >,
   TSelected = TSearch,
->(
-  opts: StrictOrFrom<TFrom, TReturnIntersection> & {
-    select?: (search: TSearch) => TSelected
-  },
-): TSelected {
+>(opts: UseSearchOptions<TFrom, TStrict, TSearch, TSelected>): TSelected {
   return useMatch({
     ...opts,
     select: (match: MakeRouteMatch<TRouteTree, TFrom>) => {
diff --git a/packages/react-router/src/utils.ts b/packages/react-router/src/utils.ts
index 6f33f7c18..95ef366f4 100644
--- a/packages/react-router/src/utils.ts
+++ b/packages/react-router/src/utils.ts
@@ -69,18 +69,38 @@ export type NonNullableUpdater<TPrevious, TResult = TPrevious> =
   | TResult
   | ((prev: TPrevious) => TResult)
 
-// from https://github.com/type-challenges/type-challenges/issues/737
-type LastInUnion<T> =
-  UnionToIntersection<T extends unknown ? (x: T) => 0 : never> extends (
-    x: infer L,
-  ) => 0
-    ? L
+export type MergeUnionObjects<TUnion> = TUnion extends MergeUnionPrimitive
+  ? never
+  : TUnion
+
+export type MergeUnionObject<TUnion> =
+  MergeUnionObjects<TUnion> extends infer TObj
+    ? {
+        [TKey in TObj extends any ? keyof TObj : never]?: TObj extends any
+          ? TKey extends keyof TObj
+            ? TObj[TKey]
+            : never
+          : never
+      }
     : never
-export type UnionToTuple<T, TLast = LastInUnion<T>> = [T] extends [never]
-  ? []
-  : [...UnionToTuple<Exclude<T, TLast>>, TLast]
 
-//
+export type MergeUnionPrimitive =
+  | ReadonlyArray<any>
+  | number
+  | string
+  | bigint
+  | boolean
+  | symbol
+
+export type MergeUnionPrimitives<TUnion> = TUnion extends MergeUnionPrimitive
+  ? TUnion
+  : TUnion extends object
+    ? never
+    : TUnion
+
+export type MergeUnion<TUnion> =
+  | MergeUnionPrimitives<TUnion>
+  | MergeUnionObject<MergeUnionObjects<TUnion>>
 
 export function last<T>(arr: Array<T>) {
   return arr[arr.length - 1]
@@ -268,15 +288,17 @@ export type StringLiteral<T> = T extends string
     : T
   : never
 
-export type StrictOrFrom<TFrom, TReturnIntersection extends boolean = false> =
-  | {
-      from: StringLiteral<TFrom> | TFrom
-      strict?: true
-    }
-  | {
+export type StrictOrFrom<
+  TFrom,
+  TStrict extends boolean = true,
+> = TStrict extends false
+  ? {
       from?: never
-      strict: false
-      experimental_returnIntersection?: TReturnIntersection
+      strict: TStrict
+    }
+  : {
+      from: StringLiteral<TFrom> | TFrom
+      strict?: TStrict
     }
 
 export const useLayoutEffect =


Test patch:
diff --git a/packages/react-router/tests/redirects.test.-d.tsx b/packages/react-router/tests/redirects.test-d.tsx
similarity index 100%
rename from packages/react-router/tests/redirects.test.-d.tsx
rename to packages/react-router/tests/redirects.test-d.tsx
diff --git a/packages/react-router/tests/useLoaderData.test-d.tsx b/packages/react-router/tests/useLoaderData.test-d.tsx
new file mode 100644
index 000000000..9c3cba78e
--- /dev/null
+++ b/packages/react-router/tests/useLoaderData.test-d.tsx
@@ -0,0 +1,293 @@
+import { expectTypeOf, test } from 'vitest'
+import {
+  createRootRoute,
+  createRoute,
+  createRouter,
+  useLoaderData,
+} from '../src'
+import { type MakeRouteMatch } from '../src/Matches'
+
+test('when there is no loaders', () => {
+  const rootRoute = createRootRoute()
+
+  const indexRoute = createRoute({
+    getParentRoute: () => rootRoute,
+    path: '/',
+  })
+
+  const invoicesRoute = createRoute({
+    getParentRoute: () => rootRoute,
+    path: 'invoices',
+  })
+
+  const invoicesIndexRoute = createRoute({
+    getParentRoute: () => invoicesRoute,
+    path: '/',
+  })
+
+  const routeTree = rootRoute.addChildren([
+    invoicesRoute.addChildren([invoicesIndexRoute]),
+    indexRoute,
+  ])
+
+  const defaultRouter = createRouter({
+    routeTree,
+  })
+
+  type DefaultRouter = typeof defaultRouter
+
+  expectTypeOf(useLoaderData<DefaultRouter['routeTree']>)
+    .parameter(0)
+    .toHaveProperty('from')
+    .toEqualTypeOf<'/invoices' | '__root__' | '/invoices/' | '/'>()
+
+  expectTypeOf(useLoaderData<DefaultRouter['routeTree']>)
+    .parameter(0)
+    .toHaveProperty('strict')
+    .toEqualTypeOf<true | undefined>()
+
+  expectTypeOf(useLoaderData<DefaultRouter['routeTree'], '/'>)
+    .parameter(0)
+    .toHaveProperty('select')
+    .parameter(0)
+    .toMatchTypeOf<{
+      loaderData?: {}
+    }>()
+
+  expectTypeOf(useLoaderData<DefaultRouter['routeTree'], '/'>)
+    .parameter(0)
+    .toHaveProperty('select')
+    .returns.toEqualTypeOf<{}>()
+
+  expectTypeOf(
+    useLoaderData<DefaultRouter['routeTree'], '/'>,
+  ).returns.toEqualTypeOf<{}>()
+
+  expectTypeOf(
+    useLoaderData<DefaultRouter['routeTree'], '/', false>({ strict: false }),
+  ).toEqualTypeOf<{}>
+})
+
+test('when there is one loader', () => {
+  const rootRoute = createRootRoute()
+
+  const indexRoute = createRoute({
+    getParentRoute: () => rootRoute,
+    path: '/',
+  })
+
+  const invoicesRoute = createRoute({
+    getParentRoute: () => rootRoute,
+    path: 'invoices',
+    loader: () => ({ data: ['element1', 'element2'] }),
+  })
+
+  const invoicesIndexRoute = createRoute({
+    getParentRoute: () => invoicesRoute,
+    path: '/',
+  })
+
+  const invoiceRoute = createRoute({
+    getParentRoute: () => invoicesRoute,
+    path: '$invoiceId',
+  })
+
+  const routeTree = rootRoute.addChildren([
+    invoicesRoute.addChildren([invoicesIndexRoute, invoiceRoute]),
+    indexRoute,
+  ])
+
+  const defaultRouter = createRouter({
+    routeTree,
+    context: { userId: 'userId' },
+  })
+
+  type DefaultRouter = typeof defaultRouter
+
+  expectTypeOf(
+    useLoaderData<DefaultRouter['routeTree'], '/invoices'>,
+  ).returns.toEqualTypeOf<{ data: Array<string> }>()
+
+  expectTypeOf(
+    useLoaderData<DefaultRouter['routeTree'], '/invoices'>,
+  ).returns.toEqualTypeOf<{ data: Array<string> }>()
+
+  expectTypeOf(useLoaderData<DefaultRouter['routeTree'], '/invoices'>)
+    .parameter(0)
+    .toHaveProperty('select')
+    .returns.toEqualTypeOf<{ data: Array<string> }>()
+
+  expectTypeOf(
+    useLoaderData<DefaultRouter['routeTree'], '/invoices', false>,
+  ).returns.toEqualTypeOf<{ data?: Array<string> }>()
+
+  expectTypeOf(useLoaderData<DefaultRouter['routeTree'], '/invoices', false>)
+    .parameter(0)
+    .toHaveProperty('select')
+    .returns.toEqualTypeOf<{ data?: Array<string> }>()
+
+  expectTypeOf(
+    useLoaderData<
+      DefaultRouter['routeTree'],
+      '/invoices',
+      false,
+      MakeRouteMatch<DefaultRouter['routeTree'], '/invoices', false>,
+      number
+    >,
+  ).returns.toEqualTypeOf<number>()
+})
+
+test('when there are multiple loaders', () => {
+  const rootRoute = createRootRoute()
+
+  const indexRoute = createRoute({
+    getParentRoute: () => rootRoute,
+    path: '/',
+  })
+
+  const invoicesRoute = createRoute({
+    getParentRoute: () => rootRoute,
+    path: 'invoices',
+    loader: () => ({ data: ['invoice1', 'invoice2'] }) as const,
+  })
+
+  const invoicesIndexRoute = createRoute({
+    getParentRoute: () => invoicesRoute,
+    path: '/',
+  })
+
+  const postsRoute = createRoute({
+    getParentRoute: () => rootRoute,
+    path: 'posts',
+    loader: () => ({ data: ['post1', 'post2'] }) as const,
+  })
+
+  const routeTree = rootRoute.addChildren([
+    invoicesRoute.addChildren([invoicesIndexRoute]),
+    postsRoute,
+    indexRoute,
+  ])
+
+  const defaultRouter = createRouter({
+    routeTree,
+  })
+
+  type DefaultRouter = typeof defaultRouter
+
+  expectTypeOf(
+    useLoaderData<DefaultRouter['routeTree'], '/'>,
+  ).returns.toEqualTypeOf<{}>()
+
+  expectTypeOf(
+    useLoaderData<DefaultRouter['routeTree'], '/invoices'>,
+  ).returns.toEqualTypeOf<{
+    readonly data: readonly ['invoice1', 'invoice2']
+  }>()
+
+  expectTypeOf(useLoaderData<DefaultRouter['routeTree'], '/invoices'>)
+    .parameter(0)
+    .toHaveProperty('select')
+    .returns.toEqualTypeOf<{
+      readonly data: readonly ['invoice1', 'invoice2']
+    }>()
+
+  expectTypeOf(
+    useLoaderData<DefaultRouter['routeTree'], '/invoices', false>,
+  ).returns.toEqualTypeOf<{
+    data?:
+      | readonly ['invoice1', 'invoice2']
+      | readonly ['post1', 'post2']
+      | undefined
+  }>()
+
+  expectTypeOf(useLoaderData<DefaultRouter['routeTree'], '/invoices', false>)
+    .parameter(0)
+    .toHaveProperty('select')
+    .returns.toEqualTypeOf<{
+      data?:
+        | readonly ['invoice1', 'invoice2']
+        | readonly ['post1', 'post2']
+        | undefined
+    }>()
+})
+
+test('when there are multiple loaders of objects and primtives', () => {
+  const rootRoute = createRootRoute()
+
+  const indexRoute = createRoute({
+    getParentRoute: () => rootRoute,
+    path: '/',
+  })
+
+  const invoicesRoute = createRoute({
+    getParentRoute: () => rootRoute,
+    path: 'invoices',
+    loader: () => ['invoice1', 'invoice2'] as const,
+  })
+
+  const invoicesIndexRoute = createRoute({
+    getParentRoute: () => invoicesRoute,
+    path: '/',
+    loader: () => ({ invoice: { id: 1 } }) as const,
+  })
+
+  const postsRoute = createRoute({
+    getParentRoute: () => rootRoute,
+    path: 'posts',
+    loader: () => ['post1', 'post2'] as const,
+  })
+
+  const routeTree = rootRoute.addChildren([
+    invoicesRoute.addChildren([invoicesIndexRoute]),
+    indexRoute,
+    postsRoute,
+  ])
+
+  const defaultRouter = createRouter({
+    routeTree,
+  })
+
+  type DefaultRouter = typeof defaultRouter
+
+  expectTypeOf(
+    useLoaderData<DefaultRouter['routeTree'], '/'>,
+  ).returns.toEqualTypeOf<{}>()
+
+  expectTypeOf(
+    useLoaderData<DefaultRouter['routeTree'], '/invoices'>,
+  ).returns.toEqualTypeOf<readonly ['invoice1', 'invoice2']>()
+
+  expectTypeOf(useLoaderData<DefaultRouter['routeTree'], '/invoices'>)
+    .parameter(0)
+    .toHaveProperty('select')
+    .returns.toEqualTypeOf<readonly ['invoice1', 'invoice2']>()
+
+  expectTypeOf(
+    useLoaderData<DefaultRouter['routeTree'], '/invoices', false>,
+  ).returns.toEqualTypeOf<
+    | readonly ['invoice1', 'invoice2']
+    | readonly ['post1', 'post2']
+    | {
+        invoice?:
+          | {
+              readonly id: 1
+            }
+          | undefined
+      }
+  >()
+
+  expectTypeOf(useLoaderData<DefaultRouter['routeTree'], '/invoices', false>)
+    .parameter(0)
+    .toHaveProperty('select')
+    .returns.toEqualTypeOf<
+      | readonly ['invoice1', 'invoice2']
+      | readonly ['post1', 'post2']
+      | {
+          invoice?:
+            | {
+                readonly id: 1
+              }
+            | undefined
+        }
+    >()
+})
diff --git a/packages/react-router/tests/useParams.test-d.tsx b/packages/react-router/tests/useParams.test-d.tsx
new file mode 100644
index 000000000..cf1e94080
--- /dev/null
+++ b/packages/react-router/tests/useParams.test-d.tsx
@@ -0,0 +1,217 @@
+import { expectTypeOf, test } from 'vitest'
+import {
+  type FullSearchSchema,
+  createRootRoute,
+  createRoute,
+  createRouter,
+  useParams,
+} from '../src'
+
+test('when there are no params', () => {
+  const rootRoute = createRootRoute()
+
+  const indexRoute = createRoute({
+    getParentRoute: () => rootRoute,
+    path: '/',
+  })
+
+  const invoicesRoute = createRoute({
+    getParentRoute: () => rootRoute,
+    path: 'invoices',
+  })
+
+  const invoicesIndexRoute = createRoute({
+    getParentRoute: () => invoicesRoute,
+    path: '/',
+  })
+
+  const routeTree = rootRoute.addChildren([
+    invoicesRoute.addChildren([invoicesIndexRoute]),
+    indexRoute,
+  ])
+
+  const defaultRouter = createRouter({
+    routeTree,
+  })
+
+  type DefaultRouter = typeof defaultRouter
+
+  expectTypeOf(useParams<DefaultRouter['routeTree']>)
+    .parameter(0)
+    .toHaveProperty('from')
+    .toEqualTypeOf<'/invoices' | '__root__' | '/invoices/' | '/'>()
+
+  expectTypeOf(useParams<DefaultRouter['routeTree']>)
+    .parameter(0)
+    .toHaveProperty('strict')
+    .toEqualTypeOf<true | undefined>()
+
+  expectTypeOf(useParams<DefaultRouter['routeTree'], '/'>)
+    .parameter(0)
+    .toHaveProperty('select')
+    .parameter(0)
+    .toEqualTypeOf<{}>()
+
+  expectTypeOf(useParams<DefaultRouter['routeTree'], '/'>)
+    .parameter(0)
+    .toHaveProperty('select')
+    .returns.toEqualTypeOf<{}>()
+
+  expectTypeOf(
+    useParams<DefaultRouter['routeTree'], '/'>,
+  ).returns.toEqualTypeOf<{}>()
+
+  expectTypeOf(
+    useParams<DefaultRouter['routeTree'], '/', false>({ strict: false }),
+  ).toEqualTypeOf<{}>()
+})
+
+test('when there is one param', () => {
+  const rootRoute = createRootRoute()
+
+  const indexRoute = createRoute({
+    getParentRoute: () => rootRoute,
+    path: '/',
+  })
+
+  const invoicesRoute = createRoute({
+    getParentRoute: () => rootRoute,
+    path: 'invoices',
+  })
+
+  const invoicesIndexRoute = createRoute({
+    getParentRoute: () => invoicesRoute,
+    path: '/',
+  })
+
+  const invoiceRoute = createRoute({
+    getParentRoute: () => invoicesRoute,
+    path: '$invoiceId',
+  })
+
+  const routeTree = rootRoute.addChildren([
+    invoicesRoute.addChildren([invoicesIndexRoute, invoiceRoute]),
+    indexRoute,
+  ])
+
+  const defaultRouter = createRouter({
+    routeTree,
+  })
+
+  type DefaultRouter = typeof defaultRouter
+
+  expectTypeOf(
+    useParams<DefaultRouter['routeTree'], '/'>,
+  ).returns.toEqualTypeOf<{}>()
+
+  expectTypeOf(
+    useParams<DefaultRouter['routeTree'], '/invoices/$invoiceId'>,
+  ).returns.toEqualTypeOf<{ invoiceId: string }>()
+
+  expectTypeOf(useParams<DefaultRouter['routeTree'], '/invoices/$invoiceId'>)
+    .parameter(0)
+    .toHaveProperty('select')
+    .toEqualTypeOf<
+      ((search: { invoiceId: string }) => { invoiceId: string }) | undefined
+    >()
+
+  expectTypeOf(
+    useParams<DefaultRouter['routeTree'], '/invoices', false>,
+  ).returns.toEqualTypeOf<{ invoiceId?: string }>()
+
+  expectTypeOf(useParams<DefaultRouter['routeTree'], '/invoices', false>)
+    .parameter(0)
+    .toHaveProperty('select')
+    .toEqualTypeOf<
+      ((search: { invoiceId?: string }) => { invoiceId?: string }) | undefined
+    >()
+
+  expectTypeOf(
+    useParams<
+      DefaultRouter['routeTree'],
+      '/invoices',
+      false,
+      FullSearchSchema<DefaultRouter['routeTree']>,
+      number
+    >,
+  ).returns.toEqualTypeOf<number>()
+})
+
+test('when there are multiple params', () => {
+  const rootRoute = createRootRoute()
+
+  const indexRoute = createRoute({
+    getParentRoute: () => rootRoute,
+    path: '/',
+  })
+
+  const invoicesRoute = createRoute({
+    getParentRoute: () => rootRoute,
+    path: 'invoices',
+  })
+
+  const invoicesIndexRoute = createRoute({
+    getParentRoute: () => invoicesRoute,
+    path: '/',
+  })
+
+  const invoiceRoute = createRoute({
+    getParentRoute: () => invoicesRoute,
+    path: '$invoiceId',
+  })
+
+  const postsRoute = createRoute({
+    getParentRoute: () => rootRoute,
+    path: 'posts',
+  })
+
+  const postRoute = createRoute({
+    getParentRoute: () => postsRoute,
+    path: '$postId',
+  })
+
+  const routeTree = rootRoute.addChildren([
+    invoicesRoute.addChildren([
+      invoicesIndexRoute,
+      invoiceRoute,
+      postsRoute.addChildren([postRoute]),
+    ]),
+    indexRoute,
+  ])
+
+  const defaultRouter = createRouter({
+    routeTree,
+  })
+
+  type DefaultRouter = typeof defaultRouter
+
+  expectTypeOf(
+    useParams<DefaultRouter['routeTree'], '/'>,
+  ).returns.toEqualTypeOf<{}>()
+
+  expectTypeOf(
+    useParams<DefaultRouter['routeTree'], '/invoices/$invoiceId'>,
+  ).returns.toEqualTypeOf<{ invoiceId: string }>()
+
+  expectTypeOf(useParams<DefaultRouter['routeTree'], '/invoices/$invoiceId'>)
+    .parameter(0)
+    .toHaveProperty('select')
+    .toEqualTypeOf<
+      ((search: { invoiceId: string }) => { invoiceId: string }) | undefined
+    >()
+
+  expectTypeOf(
+    useParams<DefaultRouter['routeTree'], '/invoices', false>,
+  ).returns.toEqualTypeOf<{ invoiceId?: string; postId?: string }>()
+
+  expectTypeOf(useParams<DefaultRouter['routeTree'], '/invoices', false>)
+    .parameter(0)
+    .toHaveProperty('select')
+    .toEqualTypeOf<
+      | ((search: { invoiceId?: string; postId?: string }) => {
+          invoiceId?: string
+          postId?: string
+        })
+      | undefined
+    >()
+})
diff --git a/packages/react-router/tests/useRouteContext.test-d.tsx b/packages/react-router/tests/useRouteContext.test-d.tsx
new file mode 100644
index 000000000..f29713def
--- /dev/null
+++ b/packages/react-router/tests/useRouteContext.test-d.tsx
@@ -0,0 +1,334 @@
+import { expectTypeOf, test } from 'vitest'
+import {
+  type FullSearchSchema,
+  createRootRoute,
+  createRootRouteWithContext,
+  createRoute,
+  createRouter,
+  useRouteContext,
+} from '../src'
+
+test('when there is no context', () => {
+  const rootRoute = createRootRoute()
+
+  const indexRoute = createRoute({
+    getParentRoute: () => rootRoute,
+    path: '/',
+  })
+
+  const invoicesRoute = createRoute({
+    getParentRoute: () => rootRoute,
+    path: 'invoices',
+  })
+
+  const invoicesIndexRoute = createRoute({
+    getParentRoute: () => invoicesRoute,
+    path: '/',
+  })
+
+  const routeTree = rootRoute.addChildren([
+    invoicesRoute.addChildren([invoicesIndexRoute]),
+    indexRoute,
+  ])
+
+  const defaultRouter = createRouter({
+    routeTree,
+  })
+
+  type DefaultRouter = typeof defaultRouter
+
+  expectTypeOf(useRouteContext<DefaultRouter['routeTree']>)
+    .parameter(0)
+    .toHaveProperty('from')
+    .toEqualTypeOf<'/invoices' | '__root__' | '/invoices/' | '/'>()
+
+  expectTypeOf(useRouteContext<DefaultRouter['routeTree']>)
+    .parameter(0)
+    .toHaveProperty('strict')
+    .toEqualTypeOf<true | undefined>()
+
+  expectTypeOf(useRouteContext<DefaultRouter['routeTree'], '/'>)
+    .parameter(0)
+    .toHaveProperty('select')
+    .parameter(0)
+    .toEqualTypeOf<{}>()
+
+  expectTypeOf(useRouteContext<DefaultRouter['routeTree'], '/'>)
+    .parameter(0)
+    .toHaveProperty('select')
+    .returns.toEqualTypeOf<{}>()
+
+  expectTypeOf(
+    useRouteContext<DefaultRouter['routeTree'], '/'>,
+  ).returns.toEqualTypeOf<{}>()
+
+  expectTypeOf(
+    useRouteContext<DefaultRouter['routeTree'], '/', false>({ strict: false }),
+  ).toEqualTypeOf<{}>()
+})
+
+test('when there is the root context', () => {
+  interface Context {
+    userId: string
+  }
+
+  const rootRoute = createRootRouteWithContext<Context>()()
+
+  const indexRoute = createRoute({
+    getParentRoute: () => rootRoute,
+    path: '/',
+  })
+
+  const invoicesRoute = createRoute({
+    getParentRoute: () => rootRoute,
+    path: 'invoices',
+  })
+
+  const invoicesIndexRoute = createRoute({
+    getParentRoute: () => invoicesRoute,
+    path: '/',
+  })
+
+  const invoiceRoute = createRoute({
+    getParentRoute: () => invoicesRoute,
+    path: '$invoiceId',
+  })
+
+  const routeTree = rootRoute.addChildren([
+    invoicesRoute.addChildren([invoicesIndexRoute, invoiceRoute]),
+    indexRoute,
+  ])
+
+  const defaultRouter = createRouter({
+    routeTree,
+    context: { userId: 'userId' },
+  })
+
+  type DefaultRouter = typeof defaultRouter
+
+  expectTypeOf(
+    useRouteContext<DefaultRouter['routeTree'], '/'>,
+  ).returns.toEqualTypeOf<{ userId: string }>()
+
+  expectTypeOf(
+    useRouteContext<DefaultRouter['routeTree'], '/invoices/$invoiceId'>,
+  ).returns.toEqualTypeOf<{ userId: string }>()
+
+  expectTypeOf(
+    useRouteContext<DefaultRouter['routeTree'], '/invoices/$invoiceId'>,
+  )
+    .parameter(0)
+    .toHaveProperty('select')
+    .toEqualTypeOf<
+      ((search: { userId: string }) => { userId: string }) | undefined
+    >()
+
+  expectTypeOf(
+    useRouteContext<DefaultRouter['routeTree'], '/invoices', false>,
+  ).returns.toEqualTypeOf<{ userId?: string }>()
+
+  expectTypeOf(useRouteContext<DefaultRouter['routeTree'], '/invoices', false>)
+    .parameter(0)
+    .toHaveProperty('select')
+    .toEqualTypeOf<
+      ((search: { userId?: string }) => { userId?: string }) | undefined
+    >()
+
+  expectTypeOf(
+    useRouteContext<
+      DefaultRouter['routeTree'],
+      '/invoices',
+      false,
+      FullSearchSchema<DefaultRouter['routeTree']>,
+      number
+    >,
+  ).returns.toEqualTypeOf<number>()
+})
+
+test('when there are multiple contexts', () => {
+  interface Context {
+    userId: string
+  }
+
+  const rootRoute = createRootRouteWithContext<Context>()()
+
+  const indexRoute = createRoute({
+    getParentRoute: () => rootRoute,
+    path: '/',
+  })
+
+  const invoicesRoute = createRoute({
+    getParentRoute: () => rootRoute,
+    path: 'invoices',
+  })
+
+  const invoicesIndexRoute = createRoute({
+    getParentRoute: () => invoicesRoute,
+    path: '/',
+  })
+
+  const invoiceRoute = createRoute({
+    getParentRoute: () => invoicesRoute,
+    path: '$invoiceId',
+  })
+
+  const postsRoute = createRoute({
+    getParentRoute: () => rootRoute,
+    path: 'posts',
+  })
+
+  const postRoute = createRoute({
+    getParentRoute: () => postsRoute,
+    path: '$postId',
+    beforeLoad: () => ({ username: 'username' }),
+  })
+
+  const routeTree = rootRoute.addChildren([
+    invoicesRoute.addChildren([
+      invoicesIndexRoute,
+      invoiceRoute,
+      postsRoute.addChildren([postRoute]),
+    ]),
+    indexRoute,
+  ])
+
+  const defaultRouter = createRouter({
+    routeTree,
+    context: { userId: 'userId' },
+  })
+
+  type DefaultRouter = typeof defaultRouter
+
+  expectTypeOf(
+    useRouteContext<DefaultRouter['routeTree'], '/'>,
+  ).returns.toEqualTypeOf<{ userId: string }>()
+
+  expectTypeOf(
+    useRouteContext<DefaultRouter['routeTree'], '/invoices/$invoiceId'>,
+  ).returns.toEqualTypeOf<{ userId: string }>()
+
+  expectTypeOf(
+    useRouteContext<DefaultRouter['routeTree'], '/invoices/$invoiceId'>,
+  )
+    .parameter(0)
+    .toHaveProperty('select')
+    .toEqualTypeOf<
+      ((search: { userId: string }) => { userId: string }) | undefined
+    >()
+
+  expectTypeOf(
+    useRouteContext<DefaultRouter['routeTree'], '/invoices', false>,
+  ).returns.toEqualTypeOf<{ userId?: string; username?: string }>()
+
+  expectTypeOf(useRouteContext<DefaultRouter['routeTree'], '/invoices', false>)
+    .parameter(0)
+    .toHaveProperty('select')
+    .toEqualTypeOf<
+      | ((search: { userId?: string; username?: string }) => {
+          userId?: string
+          username?: string
+        })
+      | undefined
+    >()
+})
+
+test('when there are overlapping contexts', () => {
+  interface Context {
+    userId: string
+  }
+
+  const rootRoute = createRootRouteWithContext<Context>()()
+
+  const indexRoute = createRoute({
+    getParentRoute: () => rootRoute,
+    path: '/',
+  })
+
+  const invoicesRoute = createRoute({
+    getParentRoute: () => rootRoute,
+    path: 'invoices',
+    validateSearch: () => ({ page: 0 }),
+  })
+
+  const invoicesIndexRoute = createRoute({
+    getParentRoute: () => invoicesRoute,
+    path: '/',
+  })
+
+  const invoiceRoute = createRoute({
+    getParentRoute: () => invoicesRoute,
+    path: '$invoiceId',
+    beforeLoad: () => ({ username: 'username2' }) as const,
+  })
+
+  const postsRoute = createRoute({
+    getParentRoute: () => rootRoute,
+    path: 'posts',
+  })
+
+  const postRoute = createRoute({
+    getParentRoute: () => postsRoute,
+    path: '$postId',
+    beforeLoad: () => ({ username: 'username1' }) as const,
+  })
+
+  const routeTree = rootRoute.addChildren([
+    invoicesRoute.addChildren([
+      invoicesIndexRoute,
+      invoiceRoute,
+      postsRoute.addChildren([postRoute]),
+    ]),
+    indexRoute,
+  ])
+
+  const defaultRouter = createRouter({
+    routeTree,
+    context: { userId: 'userId' },
+  })
+
+  type DefaultRouter = typeof defaultRouter
+
+  expectTypeOf(useRouteContext<DefaultRouter['routeTree'], '/'>).returns
+    .toEqualTypeOf<{ userId: string }>
+
+  expectTypeOf(
+    useRouteContext<DefaultRouter['routeTree'], '/invoices/$invoiceId'>,
+  ).returns.toEqualTypeOf<{
+    userId: string
+    readonly username: 'username2'
+  }>()
+
+  expectTypeOf(
+    useRouteContext<DefaultRouter['routeTree'], '/invoices/$invoiceId'>,
+  )
+    .parameter(0)
+    .toHaveProperty('select')
+    .toEqualTypeOf<
+      | ((search: { userId: string; readonly username: 'username2' }) => {
+          userId: string
+          readonly username: 'username2'
+        })
+      | undefined
+    >()
+
+  expectTypeOf(
+    useRouteContext<DefaultRouter['routeTree'], '/invoices', false>,
+  ).returns.toEqualTypeOf<{
+    userId?: string
+    username?: 'username1' | 'username2'
+  }>()
+
+  expectTypeOf(useRouteContext<DefaultRouter['routeTree'], '/invoices', false>)
+    .parameter(0)
+    .toHaveProperty('select')
+    .toEqualTypeOf<
+      | ((search: {
+          userId?: string
+          username?: 'username2' | 'username1'
+        }) => {
+          userId?: string
+          username?: 'username2' | 'username1'
+        })
+      | undefined
+    >()
+})
diff --git a/packages/react-router/tests/useSearch.test-d.tsx b/packages/react-router/tests/useSearch.test-d.tsx
new file mode 100644
index 000000000..9687bfb5d
--- /dev/null
+++ b/packages/react-router/tests/useSearch.test-d.tsx
@@ -0,0 +1,281 @@
+import { expectTypeOf, test } from 'vitest'
+import {
+  type FullSearchSchema,
+  createRootRoute,
+  createRoute,
+  createRouter,
+  useSearch,
+} from '../src'
+
+test('when there are no search params', () => {
+  const rootRoute = createRootRoute()
+
+  const indexRoute = createRoute({
+    getParentRoute: () => rootRoute,
+    path: '/',
+  })
+
+  const invoicesRoute = createRoute({
+    getParentRoute: () => rootRoute,
+    path: 'invoices',
+  })
+
+  const invoicesIndexRoute = createRoute({
+    getParentRoute: () => invoicesRoute,
+    path: '/',
+  })
+
+  const invoiceRoute = createRoute({
+    getParentRoute: () => invoicesRoute,
+    path: '$invoiceId',
+  })
+
+  const routeTree = rootRoute.addChildren([
+    invoicesRoute.addChildren([invoicesIndexRoute, invoiceRoute]),
+    indexRoute,
+  ])
+
+  const defaultRouter = createRouter({
+    routeTree,
+  })
+
+  type DefaultRouter = typeof defaultRouter
+
+  expectTypeOf(useSearch<DefaultRouter['routeTree']>)
+    .parameter(0)
+    .toHaveProperty('from')
+    .toEqualTypeOf<
+      '/invoices' | '__root__' | '/invoices/$invoiceId' | '/invoices/' | '/'
+    >()
+
+  expectTypeOf(useSearch<DefaultRouter['routeTree']>)
+    .parameter(0)
+    .toHaveProperty('strict')
+    .toEqualTypeOf<true | undefined>()
+
+  expectTypeOf(useSearch<DefaultRouter['routeTree'], '/'>)
+    .parameter(0)
+    .toHaveProperty('select')
+    .parameter(0)
+    .toEqualTypeOf<{}>()
+
+  expectTypeOf(useSearch<DefaultRouter['routeTree'], '/'>)
+    .parameter(0)
+    .toHaveProperty('select')
+    .returns.toEqualTypeOf<{}>()
+
+  expectTypeOf(
+    useSearch<DefaultRouter['routeTree'], '/'>,
+  ).returns.toEqualTypeOf<{}>()
+
+  expectTypeOf(
+    useSearch<DefaultRouter['routeTree'], '/', false>({ strict: false }),
+  ).toEqualTypeOf<{}>()
+})
+
+test('when there is one search params', () => {
+  const rootRoute = createRootRoute()
+
+  const indexRoute = createRoute({
+    getParentRoute: () => rootRoute,
+    path: '/',
+  })
+
+  const invoicesRoute = createRoute({
+    getParentRoute: () => rootRoute,
+    path: 'invoices',
+    validateSearch: () => ({ page: 0 }),
+  })
+
+  const invoicesIndexRoute = createRoute({
+    getParentRoute: () => invoicesRoute,
+    path: '/',
+  })
+
+  const invoiceRoute = createRoute({
+    getParentRoute: () => invoicesRoute,
+    path: '$invoiceId',
+  })
+
+  const routeTree = rootRoute.addChildren([
+    invoicesRoute.addChildren([invoicesIndexRoute, invoiceRoute]),
+    indexRoute,
+  ])
+
+  const defaultRouter = createRouter({
+    routeTree,
+  })
+
+  type DefaultRouter = typeof defaultRouter
+
+  expectTypeOf(
+    useSearch<DefaultRouter['routeTree'], '/'>,
+  ).returns.toEqualTypeOf<{}>()
+
+  expectTypeOf(
+    useSearch<DefaultRouter['routeTree'], '/invoices'>,
+  ).returns.toEqualTypeOf<{ page: number }>()
+
+  expectTypeOf(useSearch<DefaultRouter['routeTree'], '/invoices'>)
+    .parameter(0)
+    .toHaveProperty('select')
+    .toEqualTypeOf<
+      ((search: { page: number }) => { page: number }) | undefined
+    >()
+
+  expectTypeOf(
+    useSearch<DefaultRouter['routeTree'], '/invoices', false>,
+  ).returns.toEqualTypeOf<{ page?: number }>()
+
+  expectTypeOf(useSearch<DefaultRouter['routeTree'], '/invoices', false>)
+    .parameter(0)
+    .toHaveProperty('select')
+    .toEqualTypeOf<
+      ((search: { page?: number }) => { page?: number }) | undefined
+    >()
+
+  expectTypeOf(
+    useSearch<
+      DefaultRouter['routeTree'],
+      '/invoices',
+      false,
+      FullSearchSchema<DefaultRouter['routeTree']>,
+      number
+    >,
+  ).returns.toEqualTypeOf<number>()
+})
+
+test('when there are multiple search params', () => {
+  const rootRoute = createRootRoute()
+
+  const indexRoute = createRoute({
+    getParentRoute: () => rootRoute,
+    path: '/',
+  })
+
+  const invoicesRoute = createRoute({
+    getParentRoute: () => rootRoute,
+    path: 'invoices',
+    validateSearch: () => ({ page: 0 }),
+  })
+
+  const invoicesIndexRoute = createRoute({
+    getParentRoute: () => invoicesRoute,
+    path: '/',
+  })
+
+  const invoiceRoute = createRoute({
+    getParentRoute: () => invoicesRoute,
+    path: '$invoiceId',
+    validateSearch: () => ({ detail: 'detail' }),
+  })
+
+  const routeTree = rootRoute.addChildren([
+    invoicesRoute.addChildren([invoicesIndexRoute, invoiceRoute]),
+    indexRoute,
+  ])
+
+  const defaultRouter = createRouter({
+    routeTree,
+  })
+
+  type DefaultRouter = typeof defaultRouter
+
+  expectTypeOf(
+    useSearch<DefaultRouter['routeTree'], '/'>,
+  ).returns.toEqualTypeOf<{}>()
+
+  expectTypeOf(
+    useSearch<DefaultRouter['routeTree'], '/invoices'>,
+  ).returns.toEqualTypeOf<{ page: number }>()
+
+  expectTypeOf(useSearch<DefaultRouter['routeTree'], '/invoices'>)
+    .parameter(0)
+    .toHaveProperty('select')
+    .toEqualTypeOf<
+      ((search: { page: number }) => { page: number }) | undefined
+    >()
+
+  expectTypeOf(
+    useSearch<DefaultRouter['routeTree'], '/invoices', false>,
+  ).returns.toEqualTypeOf<{ page?: number; detail?: string }>()
+
+  expectTypeOf(useSearch<DefaultRouter['routeTree'], '/invoices', false>)
+    .parameter(0)
+    .toHaveProperty('select')
+    .toEqualTypeOf<
+      | ((search: { page?: number; detail?: string }) => {
+          page?: number
+          detail?: string
+        })
+      | undefined
+    >()
+})
+
+test('when there are overlapping search params', () => {
+  const rootRoute = createRootRoute()
+
+  const indexRoute = createRoute({
+    getParentRoute: () => rootRoute,
+    path: '/',
+  })
+
+  const invoicesRoute = createRoute({
+    getParentRoute: () => rootRoute,
+    path: 'invoices',
+    validateSearch: () => ({ page: 0 }),
+  })
+
+  const invoicesIndexRoute = createRoute({
+    getParentRoute: () => invoicesRoute,
+    path: '/',
+    validateSearch: () => ({ detail: 50 }) as const,
+  })
+
+  const invoiceRoute = createRoute({
+    getParentRoute: () => invoicesRoute,
+    path: '$invoiceId',
+    validateSearch: () => ({ detail: 'detail' }) as const,
+  })
+
+  const routeTree = rootRoute.addChildren([
+    invoicesRoute.addChildren([invoicesIndexRoute, invoiceRoute]),
+    indexRoute,
+  ])
+
+  const defaultRouter = createRouter({
+    routeTree,
+  })
+
+  type DefaultRouter = typeof defaultRouter
+
+  expectTypeOf(
+    useSearch<DefaultRouter['routeTree'], '/'>,
+  ).returns.toEqualTypeOf<{}>()
+
+  expectTypeOf(
+    useSearch<DefaultRouter['routeTree'], '/invoices'>,
+  ).returns.toEqualTypeOf<{ page: number }>()
+
+  expectTypeOf(useSearch<DefaultRouter['routeTree'], '/invoices'>)
+    .parameter(0)
+    .toHaveProperty('select')
+    .toEqualTypeOf<
+      ((search: { page: number }) => { page: number }) | undefined
+    >()
+
+  expectTypeOf(
+    useSearch<DefaultRouter['routeTree'], '/invoices', false>,
+  ).returns.toEqualTypeOf<{ page?: number; detail?: 'detail' | 50 }>()
+
+  expectTypeOf(useSearch<DefaultRouter['routeTree'], '/invoices', false>)
+    .parameter(0)
+    .toHaveProperty('select')
+    .toEqualTypeOf<
+      | ((search: { page?: number; detail?: 'detail' | 50 }) => {
+          page?: number
+          detail?: 'detail' | 50
+        })
+      | undefined
+    >()
+})