React Router's lazy type handling and overcoming the impact with type-safe solutions

React Router is a popular library for managing routing in React applications. However, a recent change has displayed a level of arbitrariness and laziness that may negatively impact developers who seek robust type checking.
Charles
CharlesDeveloper
June 19, 20237 min read
React Router's lazy type handling and overcoming the impact with type-safe solutions

Background

React Router is a popular library for managing routing in React applications. In our CIAM (Customer Identity and Access Management) solution - Logto - we are using React as well as React Router extensively in Admin Console and all crucial user flows, such as user sign-in and registration, for both OSS distributions and Cloud platform.

We have chosen React and React Router due to their strong communities and comprehensive TypeScript support, which is vital in modern JavaScript development.

React Router offers various hooks to access routing information, including the useLocation hook. However, a recent change in the library's 6.9.0 release altered the type handling in the useLocation hook, opting for the permissive any type instead of the safer unknown type. This decision has raised concerns among developers who prioritize type safety and rely on TypeScript's powerful type inference capabilities. By neglecting to provide accurate type annotations, React Router has displayed a level of arbitrariness and laziness that may negatively impact developers who seek robust type checking.

The importance of type safety in TypeScript

TypeScript is highly valued by developers for its ability to catch potential bugs at compile-time through static type checking. By providing a strong type system, TypeScript allows developers to express complex data structures, enforce constraints, and build more reliable and maintainable code. However, to fully harness the benefits of TypeScript, it is essential for libraries and frameworks to embrace type safety and ensure accurate type annotations at all times.

React Router's use of 'any' in the useLocation hook

The useLocation hook in React Router is a critical tool for accessing the current URL's location object, which includes properties such as pathname, search, hash, and state. The problematic change introduced in version 6.9.0 revolves around the state property, which allows the passing of arbitrary objects when navigating to another route. Previously, the useLocation hook returned the state property as the safer unknown type. However, the recent update replaced it with the permissive any type, removing all type information and opening the door to potential issues.

The pitfalls of 'any'

The use of the any type in TypeScript poses significant pitfalls. By allowing any value to be assigned, it bypasses static type checking and undermines the benefits offered by TypeScript. Although it can provide a quick fix when the type is unknown, it should be used cautiously.

There are specific situations where the use of the any type can be considered. For example, when migrating a JavaScript project to TypeScript, it can be utilized to handle variables that have not yet been migrated to their respective types. Additionally, in rare cases where third-party libraries utilize the any type in their code, it may serve as a temporary workaround until a more appropriate solution can be implemented.

Overusing the any type can introduce bugs, reduce code reliability, and hinder the advantages of static typing. Hence, any is strongly not recommended in all lint rules.

Alternatives to 'any'

When you encounter a situation where the type of a variable is uncertain, use the unknown type is the recommended approach, just like what the name suggests - unknown. Unlike the more lenient any type, unknown maintains a similar behavior but imposes stricter type checking.

One of the significant distinctions between any and unknown becomes apparent when attempting to assign them to other variables. If a variable belongs to the any type, it can be assigned to another variable without encountering errors. However, trying to assign an unknown type variable to a variable with a specific type will result in an error.

Furthermore, you can’t directly access properties from an unknown type variable, until it has been narrowed down to a specific type through appropriate type checking. Learn more

Here’s an example of a simple type checking function that narrows down an unknown type to a known type, ensuring the safety of accessing the required properties from the object.

type Foo = {
  bar: string;
};

// A simple type narrowing function
const isFoo = (object: unknown): object is Foo =>
  object !== null && typeof object === 'object' && 'bar' in object;

// Declare an unknown object
const foo: unknown = { bar: 'bar' };

// Compilation error: 'foo' is of type 'unknown'.
// console.log(foo.bar);

if (isFoo(foo)) {
  // Unknown object 'foo' is narrowed to a known type 'Foo'
  console.log(foo.bar); // Outputs: 'bar'
}

You can also utilize third party libraries such as zod or superstruct to write complex type guards and checker functions. (Learn more about type narrowing).

The need for 'unknown' in useLocation

As we have already mentioned above, when accessing and using the state object from the useLocation hook, the following lint errors arises and further causes build failures in our codebase.

Lint error when accessing any type object Lint error when using any type variable

If you are the developers who are facing the challenges after upgrading the library, what would you do? Disabling these lint errors with // eslint-disable-next-line would be a quick workaround, but it undermines the very essence of TypeScript’s spirit, which revolves around leveraging static typing to detect potential errors early in the development process.

Consider the scenario where you pass an object through state when navigating from page A to page B. If you disable the lint errors related to the any state issue, TypeScript loses its ability to warn you about potential bugs. If you modify the object in page A but forget to update the corresponding code in page B, TypeScript won't catch the bug. Utilizing the unknown type, on the other hand, would force developers to apply appropriate type guards to narrow it down to a specific data type, which ensures the data type safety in both page A and page B, leading to more robust code and fewer runtime errors.

We tried to contact the developers of React Router, but their response was more or less like a “Nah, we are OK with any, who bothers to use unknown anyway.”

Well… As faithful TypeScript practitioners as we are, now what?

Overcoming the impact: Module augmentation through custom declaration files (d.ts)

Despite React Router’s reluctance to address this issue, developers can still mitigate the impact by leveraging custom declaration files (d.ts) in their own projects, the so called module augmentation. We had several attempts and then came up with a simplest solution. Here is an example of how to extend and augment the built-in type definitions of React Router with minimal effort:

// react-router-dom.d.ts

import 'react-router-dom';

declare module 'react-router-dom' {
  import { type Location } from 'react-router-dom';

  function useLocation(): Omit<Location, 'state'> & { state: unknown };
}

In the above declaration, we import everything from the react-router-dom package in the first place, and only extend the useLocation hook by tailoring its return type. This ensures the useLocation hook returning the correct state type, leaving everything else intact in the React Router.

Conclusion

React Router's decision to switch from the safer unknown type to the permissive any type in the useLocation hook is an arbitrariness and lazy approach that neglects the importance of type safety and TypeScript's benefits. This change creates challenge for developers who value type inference and strive for statically typed code.

However, developers can still overcome the impact and restore type safety by extending and overwriting the library's type definitions, through the use of a custom declaration file in their projects.

In the broader context, it is crucial for library maintainers to ensure type safety and provide accurate type definitions. This commitment ensures that all developers can fully leverage TypeScript's potential, resulting in a codebase that is more robust, maintainable, and less prone to errors. As the maintainers of Logto, we deeply value type safety and are committed to enhancing the developer experience by ensuring accurate type definitions and consistently delivering high-quality code.

If you haven’t heard about Logto, try it today for free!