DEV Community

강재구
강재구

Posted on

Type-Safe Routing in React Without Redefining Routes (No Giant Constant Objects)

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;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

And I imagined referencing routes like:

ROUTE.APP
ROUTE.APP.HOME.DIARY.DIARYDETAIL.EDIT
ROUTE.APP.DASHBOARD
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

Or duplicating path definitions inside the same object

export const diaryRoutes = {
  root: "/diary",
  diaryDetail: "/diary/:diaryId",
  edit: "/diary/:diaryId/edit",
} as const;
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

But I usually use the newer approach:

const router = createBrowserRouter([
  {
    path: "/",
    element: <Root />,
    loader: loadRootData,
  },
]);
Enter fullscreen mode Exit fullscreen mode

I checked my package.json:

{
  "react-router": "6",
  "react-router-dom": "6"
}
Enter fullscreen mode Exit fullscreen mode

But something felt off, so I checked again:

npm list react-router-dom
└── react-router-dom@6.30.0
Enter fullscreen mode Exit fullscreen mode

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[];
Enter fullscreen mode Exit fullscreen mode

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>;
Enter fullscreen mode Exit fullscreen mode

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> = ...
Enter fullscreen mode Exit fullscreen mode

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, /e show 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)