import type { Register } from "react-router";
type Pages = Register extends { pages: infer P } ? P : {};We get the Pages type from React Router's global Register interface. This type contains all your declared routes (e.g., "/posts/:slug").
type RouteDefinition = keyof Pages;
type AppRoutePath = ReplaceParams<RouteDefinition>;This type transforms route keys into template literal types.
- Input:
"/posts/:slug" - Output:
"/posts/${string}"
Purpose: It provides excellent autocomplete in your IDE. Limitation: TypeScript's template literal types are greedy.
ReplaceParamsconverts"/posts/:slug"to"/posts/${string}".- The type
${string}matches anything, including slashes (/). - Therefore,
"/posts/${string}"matches"/posts/my-slug", but it also matches"/posts/my-slug/extra/segments".
This is why AppRoutePath alone provides autocomplete but not strict validation. We need AppRoute<T> to enforce the structure segment-by-segment.
type AppRoute<T extends AppRoutePath> = T extends ValidateRoute<T> ? T : never;This is a conditional type that strictly validates a specific string literal T. If valid, it returns T. If invalid, it returns never.
This section breaks down the specific TypeScript utility types used to achieve strict validation.
Goal: Convert a route pattern with parameters (e.g., "/users/:id") into a generic template literal (e.g., "/users/${string}").
type ReplaceParams<T extends string> =
T extends `${infer Start}/:${infer Param}/${infer Rest}`
? `${Start}/${string}/${ReplaceParams<Rest>}`
: T extends `${infer Start}/:${infer Param}`
? `${Start}/${string}`
: T;Logic:
- Middle Param: Checks if there is a parameter in the middle of the string (
/:Param/). If so, replaces it with/${string}/and recurses on theRest. - End Param: Checks if the string ends with a parameter (
/:Param). If so, replaces it with/${string}. - Base Case: If no parameters are found, returns
Tas is.
Goal: Check if a string segment is a dynamic parameter (starts with :).
type IsParam<S extends string> = Utils.MatchPattern<S, `:${string}`>;Goal: Compare two tuples of path segments to see if they match. This is the core of the strict validation.
type MatchRouteSegments<
Pattern extends string[],
Candidate extends string[],
Depth extends any[] = Utils.DefaultRecursionLimit
> = Depth["length"] extends 0
? false // Recursion limit reached
: Utils.ConsumeMatchingSegments<Pattern, Candidate> extends {
RemainingPattern: infer RemPattern extends string[];
RemainingCandidate: infer RemCandidate extends string[];
}
? RemPattern extends []
? RemCandidate extends []
? true
: false
: RemPattern extends [
infer PHead extends string,
...infer PTail extends string[]
]
? IsParam<PHead> extends true
? RemCandidate extends [infer CHead, ...infer CTail extends string[]]
? CHead extends ""
? false
: MatchRouteSegments<
PTail,
CTail,
Depth extends [any, ...infer Rest] ? Rest : []
>
: false
: false
: false
: false;Logic:
- Recursion Limit: Checks if
Depthhas reached 0. - Static Matching (
Utils.ConsumeMatchingSegments): Delegates toUtils.ConsumeMatchingSegmentsto consume all matching static segments (e.g., matchingusersagainstusers). - Result Check:
- If both
RemainingPatternandRemainingCandidateare empty, it's a full match (true). - If
RemainingPatternis not empty, it checks if the next segment is a parameter (:id).
- If both
- Parameter Matching:
- If it is a parameter, it consumes one segment from the candidate (as long as it's not empty) and recurses.
- If it's not a parameter (and static matching failed), it returns
false.
Goal: Check if a candidate string T matches any of the valid routes defined in RouteDefinition which is a template literal union fo the declared routes from react-router.
type ValidateRoute<T extends string> = {
[P in RouteDefinition]: MatchRouteSegments<
Utils.Split<P, "/">,
Utils.Split<T, "/">
> extends true
? T
: never;
}[RouteDefinition];Logic:
- Iterates over every key
PinPages. - Splits both
P(the pattern) andT(the candidate) by/. - Runs
MatchRouteSegments. - If it matches, returns
T. If not, returnsnever. - The final result is a union of all results. If
Tmatches at least one route, the result isT. If it matches none, the result isnever.
You might wonder why we can't just use the type directly:
// ❌ Why this is annoying
const route: AppRoute<"/posts/123"> = "/posts/123";AppRoute<T> is a generic type. It needs to know what T is to validate it.
- If you write
const r: AppRoute<string>,Tisstring, which is too broad to validate. - If you write
const r: AppRoute<"/posts/123">, you are manually typing the string twice (once in the type, once in the value).
Helper functions like route and wrapper components like NavLink allow TypeScript to infer T from the value you pass in.
// ✅ route infers T = "/posts/123"
route("/posts/123");
// AppRoute<"/posts/123"> resolves to "/posts/123". All good.
// ❌ route infers T = "/posts/123/extra"
route("/posts/123/extra");
// AppRoute<"/posts/123/extra"> resolves to NEVER.
// Error: Argument of type string is not assignable to parameter of type never.By using these wrappers and helper functions, we get the best of both worlds:
- Strict Validation: Invalid paths are rejected.
- Great DX: You don't have to manually type generic parameters.
| Component | Purpose |
|---|---|
AppRoutePath |
Global type for autocomplete. Loose matching. |
AppRoute<T> |
Logic to strictly validate a specific string literal. |
route. |
Helper to infer T and enforce AppRoute. |
NavLink. |
Wrapper for <ReactRouter.NavLink> that enforces AppRoute on to. |
useNavigate |
Wrapper for ReactRouter.useNavigate that enforces AppRoute. |