Introduction
The reason I ended up writing this post started from pure coincidence.
While browsing through technical blogs(sorry its korean ref), I saw an article that made me think about path auto-completion.
When I first read the post, I genuinely thought it was impressive.
But there were parts I couldn’t immediately wrap my head around, and I kept wondering whether there was room for improvement.
In the projects I had worked on before, I didn’t even have path auto-completion—
I didn’t even have type safety.
Whenever I had to navigate the user, I simply typed the route manually, almost like hand-writing it each time.
Because of that, whenever a route changed, I had to update every single reference.
Most of the time I didn’t even remember where those references were, and this inevitably caused bugs over and over again.
So I decided that I needed path auto-completion and type safety of my own.
But I wasn’t satisfied with applying the blog’s solution as it was.
Not to criticize it, but I didn’t like the idea of redefining every route inside a single giant object.
// the post
export const RoutePath = {
root: { pathname: "/" },
userProfile: { pathname: "/user/:userId" },
commentDetail: { pathname: "/note/:noteId/comment/:commentId" },
notes: {
pathname: "/note",
search: { sort: ["POPULARITY", "UPDATED"] },
},
userNotes: {
pathname: "/user/:userId/notes",
search: { category: ["STUDY", "WORK", "PERSONAL"] },
},
} as const;
The first thing I thought deeply about was:
How can I design this in a general-purpose way?
This is actually something I realized after completing the architecture,
but redefining the root path makes no sense.
If navigation produced bugs in the past because of manually typed paths,
then after the root is redefined, the new definition becomes the next >reason for bugs.
Debugging becomes easier, but the fundamental cause remains unsolved.
And it violates DRY as well.
I kept thinking about this on the train, at home, and even before sleeping.
The next day, the first thing I did was run simulations.
I listed out possible route patterns.
/
/a/b/c
/a/:b/c
/a/b/:c
/a/:b/c/:d
...
/a/:b/c/:d/e/:f/g/:h/.../x/:y/z
Honestly… it was overwhelming.
Then I came across another article.(sorry also korean ref)
This article defined route paths using constant unions.
export const ROUTE_PATH = {
root: '/',
Home: '/home',
Dashboard: '/home/dashboard',
MyPage: '/my-page',
} as const;
At first, I thought about structuring my routing like Next.js App Router
// my prototype
export const appRoutes = {
root: "/",
home: homeRoutes,
dashBoard: dashBoardRoutes,
} as const;
And I imagined referencing routes like:
ROUTE.APP
ROUTE.APP.HOME.DIARY.DIARYDETAIL.EDIT
ROUTE.APP.DASHBOARD
Conceptually, it didn’t seem bad.
But once I started actually creating folders and dozens of index.ts files…
I realized this approach made no sense.
I had to solve the following three issues.
Expected Problems
1. Unpredictable route key structures
For example, pages like /diary/:diaryId/edit either required defining separate route objects
export const diaryEditRoutes = { edit: "/edit" } as const;
Or duplicating path definitions inside the same object
export const diaryRoutes = {
root: "/diary",
diaryDetail: "/diary/:diaryId",
edit: "/diary/:diaryId/edit",
} as const;
Both approaches are bad.
2. Forcing parameters in routes like /a/:b/c/:d/
Yes, routes that start with : enforce parameters,
but adding flags like isNeedParams felt ridiculous.
3. When routes change, every redefined route must be updated manually
Using createBrowserRouter means:
Once the real route tree changes, your manually redefined route constants become outdated immediately.
So What now?
It felt like an endless chain of absurd situations.
There was no silver bullet.
So I decided to search for the most general-purpose solution possible.
The first step was dissecting the TypeScript types of react-router-dom.
I had been using RouteObject, and I wondered: what exactly is DataRouteObject?
I remembered reading documentation around a year ago saying:
“Use the data API.”
The classic <Route>-style routing is still everywhere
<Route path="/" element={<Root />}>
<Route path="dashboard" element={<Dashboard />} />
</Route>
But I usually use the newer approach:
const router = createBrowserRouter([
{
path: "/",
element: <Root />,
loader: loadRootData,
},
]);
I checked my package.json:
{
"react-router": "6",
"react-router-dom": "6"
}
But something felt off, so I checked again:
npm list react-router-dom
└── react-router-dom@6.30.0
It was actually 6.30.0, meaning data API support was available.
After analyzing route types, I realized:
The only difference between RouteObject and DataRouteObject is the presence of id.
Also:
If index is true, children must be undefined.
If index is false, children may exist.
So what’s the plan?
To generalize route inference, I decided to extract paths directly from RouteObject.
I defined my routes like this:
export const appRoutes = [
{
// ...RouteObject[] of children or more.
},
] as const satisfies RouteObject[];
I’m not deeply experienced with TypeScript generics,
so I used GPT for parts of the process while debugging errors myself.
But as inference traversed deeper through children,
more leading slashes kept getting added.
To fix this, I checked the presence of element.
If element does not exist, the path should be excluded.
Then I wrote the final route extraction types:
// ... Omitted for brevity (same as your original)
export type AppPaths = ExtractRoutePath<typeof appRoutes>;
Now path auto-completion works.
But:
- /* appears in the suggestions
- Dynamic parameters aren’t enforced yet
So I moved on.
Extracting and enforcing path parameters
Using conditional types, I extracted parameters:
export type ExtractParamsFromPath<Path extends string> = ...
Then I implemented:
- TypedLink
- useTypedNavigate
Both enforce dynamic parameters at compile time.
This completed path stability and auto-completion.
What about 100+ routes?
With over 100 routes, VSCode’s type inference may lag or even freeze.
Conceptually, I thought about splitting inference:
-
/a,/b,/c,/d,/eshow first - Once you choose
/a, only its children get inferred
I didn’t implement this. My current project doesn’t require 100+ routes.
Conclusion
There will definitely be bugs, and I’ll fix them little by little.
That’s a problem for tomorrow’s me.
Optional Things
This work originally began as an experiment to improve my own navigation
workflow in a production React project. Over time, it became a foundation
for how I think about frontend architecture and type-driven design.
I’m revisiting and rewriting it in English to document the approach more
clearly and share it with a wider audience.
If you want to read the original Korean version of this article, you can find it here
Top comments (0)