iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🚥

A Stealthy Trend in 2021? An Overview of Type-Safe Routing

に公開

2020 was a year when type-safe routing quietly gained momentum. In this article, I will look back at the concept of type-safe routing that emerged in the TypeScript ecosystem and review its history.

What is Routing?

Routing in this article refers to the mechanism that switches content based on the URL (specifically the path part, such as /user/uhyo). Routing is primarily necessary for SPAs (Single Page Applications). In an SPA, the same HTML and JavaScript run regardless of the URL, and the JavaScript displays content corresponding to the address. Routing is truly the core of SPAs. Furthermore, general web servers also return different responses for requests to different URLs, so routing is taking place there as well.

Conventionally, string-based routing has been used. For example, in Express, a web server library for Node.js, routing is performed with code like this:

// Processing for when / is accessed
app.get("/", (req, res) => {
  res.send("Top Page");
});
// Processing for when /hello is accessed
app.get("/hello", (req, res) => {
  res.send("Hello, world!");
});
// :userId accepts any string
app.get("/user/:userId", (req, res) => {
  const userId = req.params.userId;
  res.send(`User is ${userId}`);
});

In this code example, the route is specified as a string in the first argument of app.get, and the processing for when that route is accessed is described in the function of the second argument. It is also possible to accept any string using special notations like :userId. For example, /user/:userId accepts paths such as /user/pikachu or /user/uhyo. What was actually contained in :userId can be retrieved via req.params.userId.

Let's look at another example, this time an SPA. This is an example of react-router, which has gained the status of the de facto standard router library for React.

<Switch>
  <Route path="/" exact>
    <p>Top Page</p>
  </Route>
  <Route path="/hello" exact>
    <p>Hello, world!</p>
  </Route>
  <Route path="/user/:userId" exact>
    <UserPage />
  </Route>
</Switch>

Here too, you can see that the path is specified as a string. Regarding what was contained in :userId, it can be retrieved by using the useParams hook inside the UserPage component as follows:

const UserPage = () => {
  const params = useParams<{ userId: string }>();

  return <p>User is {params.userId}</p>;
};

The Dangers of String-Based Routing

String-based routing as shown above has historically lacked type safety. In particular, the :userId seen in the examples above becomes problematic. For instance, in Express, the default type definitions (from @types/express) have req.params as an any type. Naturally, req.params.userId is also an any type.

Type of req.params in Express

There is a clear danger here. The userId appearing in both :userId and req.params.userId must, of course, match. This is because Express looks at :userId in the routing string and populates req.params.userId with the corresponding string. However, this is not apparent in the TypeScript type definitions. This is because req.params must be able to handle any routing string. Consequently, even if you make a mistake and write req.params.user, no TypeScript compilation error will occur. This is one example of the dangers of string-based routing.

A similar danger exists in the case of react-router. In the following code (reposted), a type argument of { userId: string } is passed to useParams. This is essentially telling useParams, "Please note that there is a parameter called userId."

const UserPage = () => {
  const params = useParams<{ userId: string }>();

  return <p>User is {params.pikachu}</p>;
};

The problem here is that you can deceive useParams as much as you want. For example, if you do the following, params.pikachu would be usable (even though it doesn't actually exist). In this example, while params.pikachu does not actually exist (it is undefined), it ends up being treated as a string type, and type safety is once again broken.

const UserPage = () => {
  const params = useParams<{ userId: string; pikachu: string }>();

  return <p>User is {params.pikachu}</p>;
};

Obviously, it is difficult to fix this to be type-safe. This is because UserPage is an independent component and cannot obtain information about what is outside it (<Route path="/user/:userId">).

As described above, mainstream string-based routing has the problem of poor compatibility with the type system, making it impossible to increase type safety. More specifically, because the schema of information obtained at each route is defined by strings like /user/:userId, it was difficult to incorporate that into the type system. Furthermore, as seen in the react-router example, even if schema information were available, the API was not designed to leverage it. Although react-router is used as an example here, the situation was no different for other libraries like Vue.

The Dangers of Navigation

As an accompanying issue, there was also room for improvement in the type safety of navigation. Navigation refers to making an SPA transition to another URL. For example, in react-router, navigation is performed with code like this:

history.push("/user/uhyo");

The area for improvement lurking here is that no type error occurs even if you specify a non-existent path. For example, even if there is a typo as shown below, you cannot notice the mistake until you actually run the code.

// Typo: user written as usr
history.push("/usr/uhyo");

You might wonder, "Is it really necessary to obsess over type errors to this extent?" but try experiencing complex transition flows or URL changes accompanying requirement updates. You will surely change your mind.

Three Approaches to Type-Safe Routing

The concept of type-safe routing had hardly been considered before, but in 2020, that situation changed. Three different approaches to type-safe routing emerged.

The First Turning Point: TypeScript 4.1

In September 2020, the TypeScript community was suddenly in a festive mood. This was because Template Literal Types were announced as a new feature of TypeScript 4.1. While I will leave detailed explanations to other articles, this feature significantly improved TypeScript's type-level string processing capabilities. Examples of things that became possible to implement with template literal types include type-level arithmetic, type-level JSON parsers, and type-level parser combinators.

By using template literal types, it is possible to parse a string like "/user/:userId" and determine at the type level that this path has a parameter called userId. Several such ideas were observed immediately after the appearance of template literal types. This was the first time the concept of type-safe routing saw the light of day (though it is known there were one or two attempts on a scale that was hardly noticed before then). As a specific implementation example, the following can be mentioned:

https://github.com/menduz/typed-url-params

In this library, the type ParseUrlParams<"/user/:userId"> is calculated as { userId: string }. This allows for improving the type of Express's req.params, for example.

app.get("/user/:userId", (req, res) => {
  // req.params is { userId: string }, making it type-safe!
  const userId = req.params.userId;
});

The Approach of Moving Away from String-Based Routing

Shortly before the introduction of Template Literal Types, in August 2020, I released Rocon. This is a library exactly for achieving type-safe routing in React-based SPAs. I recall that at the time of Rocon's release, while it received some appreciation, there were also skeptical eyes regarding the obsession with type safety in routing to that extent.

https://github.com/uhyo/rocon

The characteristic of this library is that it stopped using string-based routing definitions and moved to code-based definitions. Since it was before the arrival of template literal types, it was completely impossible to extract information leading to type safety from strings like "/user/:userId", so it was inevitable to discard strings to solve the safety problems described in this article. Furthermore, even now that template literal types have appeared, the approach of making string-based route definitions the source of truth lacks expressiveness (detailed later), so an approach based on non-string-based schemas like Rocon is more advantageous in terms of expressiveness.

Later, in December 2020, @fleur/froute appeared, which also has non-string-based routing functionality. This library is characterized by gaining the benefits of non-string-based routing similar to Rocon while also incorporating the advantages of template literal types. Additionally, Rocon provides an API that emphasizes CSR, while @fleur/froute provides an API that emphasizes SSR.

https://github.com/fleur-js/froute

For example, in Rocon, instead of using a string like "/user/:userId", an equivalent route is defined as follows:

// Define /user here
const toplevelRoutes = Rocon.Path().route("user");

// Define /user/:userId here
const userRoutes = toplevelRoutes._.user.attach(
  Rocon.Path()
).any("userId", {
  action: ({ userId }) => <UserPage userId={userId} />
});

The disadvantage is that it is quite verbose compared to the simplicity of a single string, but the feature of the API is that concepts like /user or /user/:userId are all represented by objects rather than strings. This can be compared to this react-router example:

// react-router example
<Route path="/user/:userId" exact>
  <UserPage />
</Route>

In react-router, userId was obtained inside the UserPage component, whereas in Rocon, userId is provided from outside UserPage. In Rocon, this userId is provided from the argument of the action callback function. Rocon takes care of extracting userId from the current URL, and type safety is ensured by making this a non-string code.

Also, navigation is done like this:

// In react-router
history.push("/user/uhyo");
// In Rocon
navigate(userRoutes.anyRoute, { userId: "uhyo "});

At first glance, now that template literal types have appeared, one might think the API is just unnecessarily verbose, but there are other advantages. I will discuss this in detail later, but two points are particularly important. One is that it supports a wide range of data sources, and the other is that it also covers the type safety of navigation, as shown in the example above.

The Code Generation Approach

While I have introduced two approaches to type-safe routing, users of frameworks like Next.js could not benefit from them at all. This is because Next.js adopted file-system-based routing, which is different from both string-based and object-based routing.

In file-system-based routing, the directory structure becomes the path structure itself. For example, in Next.js, by creating a file at pages/user/[userId].tsx, the component exported by that file automatically handles the path corresponding to /user/:userId (where [userId].tsx is literally a filename including square brackets).

If it were string-based routing, we would be dealing with strings built into the type system, but when dealing with a file system, TypeScript's type system is powerless. Among the libraries introduced in the previous section, Rocon does not support Next.js, and while @fleur/froute technically supports Next.js via its API, it is not type-safe routing.

In such a situation, the code generation approach is effective, and pathpida is what actually implemented it. This is part of the aspida family, which excels at providing type safety through code generation.

https://github.com/aspida/pathpida

By running the pathpida client, it watches the file system (the directory structure under pages) and generates TypeScript code containing that information. This code is used during navigation.

// Without pathpida
<Link href="/user/uhyo">...</Link>
// With pathpida
<Link href={pagesPath.user._userId("uhyo").$url()}>...</Link>

In this way, by using pathpida, an API is provided that receives only the dynamic parts (the [userId] part) and returns the URL string when specifying a URL. This avoids mistakes like typing /user as /usr at the type level.

In frameworks like Next.js, the router library (such as react-router) is integrated with the core of Next.js. File-system-based routing is a feature of the Next.js core (and its integrated router). Therefore, it is difficult to replace the router part of Next.js with another 3rd party library, leaving Next.js users in a situation where they must rely on code generation for type safety.

Comparison of the Three Approaches and Various Aspects of Safety

The three approaches introduced so far may seem at first glance to share the same goal of "type-safe routing," but in fact, what each aims for is slightly different. Specifically, type-safe routing can be broadly divided into two types: type safety for data reception (where the part that receives data for :userId when /user/:userId is accessed is type-safe) and type safety for navigation (where the part that constructs the URL when you want to access /user/:userId is type-safe). In addition, when considering things like Next.js support, the characteristics of each approach become clear.

The characteristics and supported areas of the three approaches are summarized in the table below.

Data Reception Type Safety Navigation Type Safety Data Source Breadth File-System-Based Support
template literal types × × ×
Object-based (Rocon, froute) ×
Code generation (pathpida) ×

Up until now, we have looked at each approach (horizontally in the table), so now let's look at the classification of safety (vertically in the table).

Type Safety in Data Reception

  • template literal types: ○
  • Rocon/froute: ○
  • pathpida: ×

What I refer to as type safety in data reception in this article means that the program responsible for a path like /user/:userId can safely retrieve what is contained in :userId at the type level. For example, if the type of userId is correctly string rather than any, and an error occurs when there is a typo like usrId, it is type-safe.

For example, template literal types achieved this safety as follows:

app.get("/user/:userId", (req, res) => {
  // req.params is { userId: string }, making it type-safe!
  const userId = req.params.userId;
});

To repost the Rocon example, in the case of Rocon, the argument passed to action in the code below is of type { userId: string } due to type inference, so this is also type-safe.

const userRoutes = toplevelRoutes._.user.attach(
  Rocon.Path()
).any("userId", {
  // { userId: string } is passed as an argument to action
  action: ({ userId }) => <UserPage userId={userId} />
});

Code generation (pathpida) is marked as × because this feature does not currently exist in pathpida. However, it is theoretically possible. Although, in that case, it would be quite tricky since it wouldn't be possible with just one generated file.

In fact, in Next.js, userId is retrieved as follows (when using getServerSideProps):

type ServerSideProps = {
  // ...
};
type Query = {
  userId: string
}

export const getServerSideProps: GetServerSideProps<ServerSideProps, Query> = async ({ context }) => {
  // type string | undefined
  const userId = context.params?.userId;
  return {
    props: {
      // ...
    }
  }
};

In this way, the :userId part is contained in context.params.userId (although context.params may be undefined). Normally, the contents of context.params are determined by the filename (pages/user/[userId].tsx), but in the code example above, the Query type is used to tell GetServerSideProps that "the contents of context.params are userId: string." Since it is possible to define this Query type incorrectly, this is not type-safe.

Pathpida currently has no support for this part (type safety in data reception).

About the Breadth of Data Sources

  • template literal types: ×
  • Rocon/froute: ○
  • pathpida: △

The table above included "Data Source Breadth," but what does this newly introduced concept refer to? It refers to where data can be received from.

Actually, until now in this article, we have only dealt with one type of data source: the path name. That is, we have been discussing the process of matching a path name like /user/uhyo to a route definition like /user/:userId and extracting uhyo which corresponds to :userId.

In addition to the path name, there are two other major data sources: query parameters and history state. Query parameters are parts of the URL like page=2 in /user/uhyo?page=2. History state is a concept from the HTML5 History API that allows saving data associated with a history entry ( a single unit of browser history) without it appearing in the URL. Particularly in SPAs, it is necessary to use these three types of data sources appropriately for the right purposes.

And the breadth of data sources is an indicator of how much these three types of data sources are supported.

Template literal types are strictly for dealing with strings like "/user/:userId", so they have no support for query parameters or history state (it might be possible to create them by putting effort into extending the string schema, but I haven't seen such an implementation yet). Therefore, it is marked as ×.

Rocon is marked as ○ because it supports all three types.

Pathpida is marked as △ because it only supports query parameters and lacks support for history state.

  • template literal types: ×
  • Rocon/froute: ○
  • pathpida: ○

Navigation type safety is the perspective of whether the part where you specify the destination URL can be written in a type-safe manner. First, let's look at an example using react-router that is not type-safe.

// Procedural example
history.push("/user/uhyo");
// Declarative example
<Link to="/user/uhyo">Link</Link>

Both are just strings, so even if you make a typo like /usr/uhyo, no type error occurs, so it is not type-safe.

Template literal types are an approach specialized for data reception type safety and offer no support for navigation, so they are marked as ×. With a string-based approach, supporting navigation would be theoretically impossible.

Object-based approaches like Rocon support navigation type safety, so they are marked as ○. Object-based approaches are the most natural way to achieve both data reception type safety and navigation type safety. Here is an example with Rocon:

// Procedural example
navigate(userRoute, { userId: "uhyo" });
// Declarative example
<Link route={userRoute} match={{ userId: "uhyo" }}>Link</Link>

Pathpida, conversely to template literal types, is an approach specialized for navigation type safety, so it is naturally marked as ○.

// Procedural example
router.push(pagesPath.user._userId("uhyo"));
// Declarative example
<Link href={pagesPath.user._userId("uhyo")}>Link</Link>

Note that while many approaches support query parameters, there are slight differences in how they are handled by each library. For example, Rocon treats parts of the path and query parameters together, while froute and pathpida treat the two as distinct.

File-System-Based Support

  • template literal types: ×
  • Rocon/froute: ×
  • pathpida: ○

As mentioned earlier, the greatest feature of pathpida is its ability to contend with file-system-based routing systems like Next.js. Other approaches are not effective in a Next.js environment.

Summary

In this article, I summarized the current state of type-safe routing, which has seen a surge in interest since the second half of 2020. I introduced three approaches for type-safe routing and explained them along with a more detailed classification of the properties of routing type safety.

If you were not yet familiar with type-safe routing, please take this opportunity to consider introducing it. For those who want to practice type-safe routing, use this article as a reference to find a library that suits you. Finally, here is the library comparison table again.

Data Reception Type Safety Navigation Type Safety Data Source Breadth File-System-Based Support
template literal types × × ×
Object-based (Rocon, froute) ×
Code generation (pathpida) ×
GitHubで編集を提案

Discussion