In TanStack Router, how does <Link to="/posts/$postId"> get checked at compile time, and what does registering the router through the Register interface do?
answer
- the route tree is a type
- declaration merging on Register
- to, params and search are checked
- from narrows, strict: false widens
basics
~20 sThe route tree's inferred type lists every path with its params and search types. Declaring interface Register { router: typeof router } for '@tanstack/react-router' hands that type to the global Link and hooks, so a bad to, params or search fails to compile.
solid answer
~40 s`createRouter({ routeTree })` returns a router whose type encodes the whole tree: every full path, its path params, its validated search params and its loader data. Exported components and hooks such as `Link`, `useNavigate` and `useParams` live in the library, so they cannot see that type until you **register** it with declaration merging: `declare module '@tanstack/react-router' { interface Register { router: typeof router } }`. After that, `to` is a union of real paths, `params` is required with exactly the keys the target needs (`{ postId: string }`), and `search` is checked against the target route's `validateSearch`. Inside a route, `Route.useParams()` and `Route.useSearch()` are typed for that route; elsewhere you pass `from` to narrow, or `strict: false` to get a union. A mismatched `from` throws at runtime.
code
tsx · 23 linesimport { createRouter, Link, RouterProvider } from "@tanstack/react-router";
import { routeTree } from "./routeTree.gen";
export const router = createRouter({ routeTree });
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}
export function PostLink({ id }: { id: string }) {
// Compile errors: to="/post/$postId" (unknown path), missing params, params={{ id }}
return (
<Link to="/posts/$postId" params={{ postId: id }}>
Open post
</Link>
);
}
export function App() {
return <RouterProvider router={router} />;
}go deeper
Know that TanStack Router flags a mistyped route path or a missing path param in Link at compile time once the router is registered.
Explain the mechanism: route definitions infer params and search types, and declaration merging on Register hands the router's type to the global Link and hooks.
Use Route-scoped hooks, from and getRouteApi deliberately, keep broad unions out of hot paths for editor performance, and rely on runtime from checks in shared components.
Weigh compile-time route safety against type-checking cost in a very large route tree, and set conventions for shared components and link helpers.
## Where the types come from TanStack Router infers types from the route definitions you already write — nothing is declared twice. - Each route's **path** (`/posts/$postId`) yields its **path params** (`postId`). - Each route's **`validateSearch`** yields the type of its **search params**. - Each route's **`loader`** return type yields its **loader data**. - Parent relationships — the generated tree in file-based routing, or `getParentRoute` in code-based routing — let children inherit what parents define. `createRouter({ routeTree })` returns a router whose TypeScript type carries all of this: effectively a map from every full path to its params, search and data. ## Why registration is needed `Link`, `useNavigate`, `useParams`, `useSearch` and `redirect` are exported from the library package. The library was compiled long before your app existed, so these exports have no way to know your routes — unless you tell TypeScript through **declaration merging**. The library exports an empty `Register` interface; you add a `router` member to it: 1. Create the router: `export const router = createRouter({ routeTree })`. 2. Augment the module: `declare module "@tanstack/react-router" { interface Register { router: typeof router } }`. 3. From then on, every exported API resolves its types from `typeof router`. This is a pure type-level operation: it changes nothing at runtime. ## What the compiler now checks | You write | TypeScript checks | |---|---| | `<Link to="/post/$postId">` (typo) | `to` must be one of the registered paths — compile error | | `<Link to="/posts/$postId">` without `params` | `params` is required and must include `postId` | | `params={{ id: "1" }}` | wrong key — must be `postId` | | `search={{ page: "2" }}` on a route whose schema says `page: number` | wrong type | | `navigate({ to: "..", from: Route.fullPath })` | relative targets resolved from a known route | Renaming a route file (and therefore its path) turns every stale `Link` into a compile error instead of a runtime 404. ## `from`, `strict: false` and route-scoped hooks Hooks that read the current route need to know *which* route the component is rendered in: - **Inside a route file**, use the route's own hooks: `Route.useParams()`, `Route.useSearch()`, `Route.useLoaderData()`. They are typed for exactly that route. - **In a component shared by several routes**, pass `from` (`useSearch({ from: "/posts/$postId" })`) to state which route you expect. If the component is actually rendered under a different route, the hook **throws at runtime**, so a wrong `from` does not silently return wrong data. - **When you truly cannot know**, pass `strict: false`. You get a relaxed type — a union across routes — and no runtime check. - `getRouteApi("/posts/$postId")` gives a code-split component the same typed hooks without importing the route file. ## A refactor, end to end Rename `src/routes/posts.$postId.tsx` to `src/routes/articles.$articleId.tsx`. The plugin regenerates the tree, so the registered router type now contains `/articles/$articleId` and no longer `/posts/$postId`. Every `<Link to="/posts/$postId">`, every `navigate({ to: "/posts/$postId" })`, every `redirect({ to: ... })` and every `useParams({ from: "/posts/$postId" })` now fails type-checking, and `params={{ postId }}` fails too because the param is now `articleId`. The editor lists every call site to fix before anything ships — the concrete payoff of registration. ## Keeping it fast The docs warn that very broad calls — `<Link to="." search={...}>` with no `from` — make TypeScript check against a union of every route's search params, which slows the editor as the app grows. Narrow with `from` or `to`, and prefer `linkOptions()` when building reusable link objects so they are checked where they are defined, not only when spread into a `Link`. ## What to say in an interview The key is the mechanism, not the slogan "it's type-safe": types are inferred from route definitions, the router's type is registered into the library through `Register`, and the exported navigation APIs then check `to`, `params` and `search` against that registered tree, with `from` and `strict` controlling how precisely a hook knows its route.
- What happens at runtime if a shared component calls useParams({ from: '/posts/$postId' }) while rendered under /users/$userId?The hook detects that the expected route is not the one being rendered and throws a runtime error. TypeScript accepted the `from` value because it names a real route, but the router verifies it at runtime rather than returning params from the wrong match.
- Does registering the router change the bundle or runtime behaviour?No. `declare module` with `interface Register` is declaration merging: it only changes the types TypeScript sees for the library's exports. The emitted JavaScript is identical.
saying these in an interview costs you the question
- Registering the router is a runtime call that installs the routes
- Link can check params without the router being registered
- A wrong from option silently returns the wrong route's params
- strict: false turns off TypeScript types entirely for that hook
- TanStack Router's types come from a separate hand-written route map