Typed routes

The obvious benefits of TypeScript is type-safety in function calls, variables assignments and so on. This post describes use-cases that are not obvious, but can still help you with your typings.

Photo by Roman Odintsov on Pexels
Update Aug 07, 2022

Things have changed (twice now) and there will probably be a new post on this topic some time. The code that is described here is no longer used in Gachou.

I add support for type parameters in this version. Then I experimented with a custom eslint-rule to generate this code

I have the strong opinion that my work is easier when I use TypeScript. I know some people don’t like this, because you often run into problems that you wouldn’t have when using plain JavaScript. It is true, that you sometimes spend a lot of time debugging types, and yes: This can be annoying. Nevertheless, when you get used to the kind of auto-completion, error detection that TypeScript gives you for your project… I don’t want to work without this anymore. And, have I mentioned the documentation that you generate when you write types? It does not get old and obsolete!

In fact, I often think: Are there errors I can make, that the compiler wouldn’t detect? Could I help the compiler to detect those errors? Here is one example.

Routing

I am using Preact in my example, because I use it in the Gachou frontend. The same techniques could be applied to React, Vue.js and other frameworks, so don’t stop reading if you are not using Preact.

This is the main component of my App, when I added the “upload”-page.

const App: FunctionalComponent = () => {
  return (
    <div id="preact_root">
      <DefaultLayout>
        <Router>
          <Route path="/" component={HomePage} />
          <Route path="/upload" component={UploadPage} />
          <NotFoundPage default />
        </Router>
      </DefaultLayout>
    </div>
  );
};

export default App;

There is a preact-router with two routes: ”/” and “upload”. There are also components that link to those routes, for example the header-navigation.

const Header: FunctionalComponent = () => {
  return (
    <nav>
      <Link activeClassName={"text-primary font-bold"} href="/">
        Home
      </Link>
      <Link activeClassName={"text-black font-bold"} href="/upload">
        Upload
      </Link>
    </nav>
  );
};

This links in this code work for now, but if we misspell the href attribute, or if we change the name of a router, we might introduce errors. The compiler won’t detect those errors, which is sad. Let’s help the compiler here by defining some types.

Defining routes

First, we have to tell the compiler, which path patterns are valid in the application. For now, we only have ”/” and “/upload”. We will take care of routes like “/files/:id” or “/search/:query” later.

type RoutePattern = "/" | "/upload";

We can now use this type in several places. For example, we can use it in the route definitions.

Update Aug 07, 2022

The code below is not used in Gachou anymore. I have encountered difficulties when using an object to define routes. I went back to declaring a <MyRoutes> component with <MyRoute> entries.

export const routes: Record<RoutePattern, FunctionalComponent> = {
  "/": HomePage,
  "/upload": UploadPage,
} as const;

export const App: FunctionalComponent = () => {
  return (
    <div id="preact_root">
      <DefaultLayout>
        <Router>
          {Object.entries(routes).map(([path, component]) => {
            return <Route key={path} path={path} component={component} />;
          })}
          <NotFoundPage default />
        </Router>
      </DefaultLayout>
    </div>
  );
};

At first, we define a literal object containing the routes and there respective page components. Then we iterate this object to generate the correct router entries.

What happens if we add a new route to the RoutePattern type? TypeScript will complain:

TS2741: Property ’“/files”’ is missing in type ’{ readonly ”/”: FunctionalComponent{}>; readonly “/upload”: FunctionalComponent{}>; }’ but required in type ‘Record {}>>’.

Unless we add the new route to our definition, TypeScript will complain about it. Better yet, when we start adding the route, we get auto-completion:

Image showing auto-completion then adding the route

This means:

  • We cannot forget to add the route.
  • It is very hard to insert typos that will cause errors.

Linking to routes

We can still create typos when creating links to the route. Can you spot it?

<Link href="/ubload">Upload</Link>

The simplest way to prevent errors here, is to define a function.

export function resolveRoute(path: RoutePattern): string {
  return path;
}

This function may seem futile, because it just returns the first parameter, but there are two reasons for using it:

  • It only allows valid route-patterns as parameter. When we use it, we omit typos.
  • We can extend it later to combine patterns and parameters into actual paths.

We can then use the function in our links:

<Link href={resolveRoute("/upload")}>Upload</Link>

Just like as in the route definitions, we get auto-completion and validation for free:

Image showing auto-completion when using "resolveRoute"

Conclusion

With some very simple techniques, we can let TypeScript perform additional checks on our code. The major takeaway here, is that TypeScript can work with strings on compile side. I’m sure there are many other places where just adding some string-literal-types can improve the developer-experience a lot.

However, the example does not cover everything that we will need in the future: What about path-parameters?

Adding support for that is an exciting prospect, because it covers more advanced typescript features, like template-literal-strings. I will write about this when I use it in the Gachou frontend, so stay tuned for the next posts.