Learning to write complex Typescript types using the example of routing in React
Are you using TypeScript but get stumped when you see types in third party libraries? Generics, generic constraint, infer, rest infer, conditional and recursive types, satisfies cause headaches? We will try to reduce the degree of complexity and write types for routing in React. This material will be useful for both frontenders and backenders.
The article assumes that you are already familiar with TypeScript, know the basics and use it in everyday development.
All keywords and concepts of TS and routing are used in the English version. And presented in a table format
Plan
Problem
Tools
Retrieving parameters from path
How Configuration Conversion Works
Satisfies, as const, type assertion
Adding the full path to the component to the tree objects
Putting it all together
Problem
What’s happened routing (routing)?
In a nutshell, this is a navigation system between screens consisting of:
Screen (screen) – the place where we need to go, in ui-libraries these are components
Route (route, route) – route configuration to the screen, may include path, redirect rules, etc.
Path (path) – the path along which the URL is formed
Static
/about
,/tasks
Parameterized
/tasks/:taskId
URL – final address formed according to path
http://tutorial/tasks/1
*These terms will be used further*
// 1. Определяем маршрут
// Маршрут до экрана
<Route
// правило, по которому формируется URL
path="/tasks/:taskId"
// экран
component={Task}
/>
// 2. Получаем URL
// <http://tutorial/tasks/1>
const url = generateUrl("/tasks/:taskId", { taskId: 1 })
// 3. Переходим по URL
navigate(url);
In Single Page applications, routing is done on the client side. And in most React apps I’ve worked with
Routing system scattered across files
// Tasks.tsx
function Tasks() {
return (
<Task path=":taskId" />
)
}
// App.tsx
function App() {
return (
<Router>
<Tasks path="tasks" />
<Router>
)
}
Application navigation is carried out using text constants in the component properties
to={'/path/to/component}
Even in the example from the documentation of the most popular react-router library, screen references are written like this
import { Outlet, Link } from "react-router-dom";
export default function Root() {
return (
<>
<div id="sidebar">
{/* other elements */}
<nav>
<ul>
<li>
<Link to={`contacts/1`}>Your Name</Link>
</li>
<li>
<Link to={`contacts/2`}>Your Friend</Link>
</li>
</ul>
</nav>
{/* other elements */}
</div>
</>
);
}
The application grows and the number of routes increases. There comes a time when you need to change the navigation and you have to manually look for files where this route is used and change it manually.
But paths don’t always occur as a full line /tasks/:taskId
but can be collected from different variables
const tasksPath="tasks";
const { taskId } = useParams()
generateUrl(`${tasksPath}/:taskid, { taskId }`)
Therefore, it is often possible to miss something when refactoring. Broken links appear in the application and users are indignant
Where do we want to go
In this tutorial, we will learn how to write complex TypeScript types using centralized routing as an example.
On the Internet you can find the TypeScript-first routing library type route, we will write from scratch. In addition, our solution is universal and works with any routing library.
What will be the result
Tree of all application routes in one place – json config
The ability to receive these routes and see hints in the IDE
Generating final URL by path
Parameter validation when generating a URL
Tools
What will we use besides TS itself
react – rendering library
type-fest – set of ts utilities
expect-type – type testing
react-router – routing library for React
Retrieving parameters from path
IN react-router There are already types for this task, but of course we will reinvent the wheel.
For this we need the following types
export type ExtractParams<Path> = Path extends `${infer Segment}/${infer Rest}`
? ExtractParam<Segment> & ExtractParams<Rest>
: ExtractParam<Path>
// Пример
type Params = ExtractParams<'/tasks/:taskId/:commentId'>
// Params = { taskId: string; commentId: string; }
export type ExtractParam<Segment> = Segment extends `:${infer Param}`
? {
[Param]: string;
}
: unknown;
// Пример
type Param = ExtractParam<':taskId'>
// Param = { taskId: string; }
These two types are the basis for validation and IDE suggestions of parameters when generating URLs based on path
ExtractParam
Let me remind you that path is a string /segment/:parameterSegment/segement2
by which the final URL is generated. Consists of the following segments
:parameterSegment
– a dynamic parameter that is replaced with a specific value in the URLsegment
– unchangeable part/
– separating slash
Let’s take a look at the first type. ExtractParam
. It converts the segment string with the parameter to object type
with the same key
export type ExtractParam<Path> = Path extends `:${infer Param}`
? {
[Param]: string;
}
: {};
// expectTypeOf - функция из библиотеки expect-type
// @ts-expect-error - комментарий из expect-type наподобии eslint-disable-next-line
it('ExtractParam type ', () => {
// { taskId: string }
expectTypeOf({ taskId: '' })
.toMatchTypeOf<ExtractParam<':taskId'>>();
// { commentId: string }
expectTypeOf({ commentId: '' })
.toMatchTypeOf<ExtractParam<':commentId'>>();
// {} вторая ветка conditional
expectTypeOf({ }).toMatchTypeOf<ExtractParam<'somestring'>>();
// @ts-expect-error
// !== { taskId: number }
expectTypeOf({ taskId: 1 }).toMatchTypeOf<ExtractParam<':taskId'>>();
// @ts-expect-error
// !== { }
expectTypeOf({ }).toEqualTypeOf<ExtractParam<':taskId'>>();
});
To make it easier to understand the work, let’s translate the type ExtractParam
into JS pseudocode.
(*I’m not saying that it works that way under the hood)
(** I borrowed this approach from the library type-typeit allows you to write complex types in JS-like notation)
export const extractParam = (path: any) => {
if (typeof path === "string" && path.startsWith(':')) {
const param = path.slice(1);
return {
[param]: '',
}
}
else {
return {}
}
}
it('extractParam func', function () {
expect(extractParam(':taskId')).toEqual({ taskId: '' });
expect(extractParam('taskId')).toEqual({ });
});
The table provides equivalents for all keywords and concepts
Concept | TS | JS |
---|---|---|
property mapping |
|
|
GENERICS |
|
|
GENERICS CONSTRAINTS |
|
|
CONDITIONAL TYPES |
|
|
INFER |
|
|
ExtractParams
Type ExtractParams
converts the string path into an object where the keys are segments with parameters, and the values are type string
export type ExtractParams<Path> = Path extends `${infer Segment}/${infer Rest}`
? ExtractParam<Segment> & ExtractParams<Rest>
: ExtractParam<Path>
it('ExtractParams', () => {
// { taskId: string; }
expectTypeOf({ taskId: '' })
.toMatchTypeOf<ExtractParams<'/:taskId'>>();
// Рекурсия ( {} & { taskId: string } & { tab: string } )
// { taskId: string; tab: string; }
expectTypeOf({ taskId: '', tab: '' })
.toMatchTypeOf<ExtractParams<'/tasks/:taskId/:tab'>>();
// Рекурсия ( {} & {} & {} )
// { }
expectTypeOf({ }).toEqualTypeOf<ExtractParams<'/path/without/params'>>();
// @ts-expect-error
// { taskId: number; }
expectTypeOf({ taskId: 1 }).toMatchTypeOf<ExtractParams<'/:taskId'>>();
})
export const extractParams = (path: string): Record<string, string> => {
const firstSlash = path.indexOf('/');
// условие прекращения рекурсии
if (firstSlash === -1) {
return extractParam(path);
}
// выделяем первый сегмент и оставшуются строку
// например [segment, rest] = ['tasks', ':taskId']
const [segment, rest] = [path.slice(0, firstSlash), path.slice(firstSlash + 1)];
return {
...extractParam(segment),
// рекурсивный вызов
...extractParams(rest)
}
}
it('extractParams func', function () {
expect(extractParams('/:taskId')).toEqual({ taskId: '' });
expect(extractParams('/tasks/:taskId/:tab')).toEqual({ taskId: '', tab: '' });
expect(extractParams('/path/without/params')).toEqual({ });
});
Note that here we use Recursive types . If you remember how recursive functions are arranged, it looks something like this
Function declaration
Recursion termination condition
Recursive call
Description | TS | JS |
---|---|---|
Announcement |
|
|
Recursion termination condition |
|
|
Recursive call |
|
|
How Configuration Conversion Works
In react-router, you can use a simple json object for the routing tree.
The children array is used to build the tree.
But the problem is that accessing by index children[0].fullPath
– cannot be used.
Therefore, you need to convert the children arrays into an object tree and add the full path to the component
Given:
specific route configuration interface from react-router
routing configuration based on this interface
a function that transforms the original configuration into the form we need
At the exit:
We get the final object which allows us to extract the paths as followsROUTES.tasks.task.fullPath
= /tasks/:taskId
With types, you need to do about the same thing: to the original interface RouteObject
from react-router add fullPath
with the full path to the screen and replace path
as a regular string, on path where there will be a constant string from the configuration
path: ':taskId'
fullPath: '/tasks/:taskId'
Satisfies, as const, type assertions
// Исходная конфигурация
export const ROUTES_CONFIG = {
id: 'root',
path: '',
element: <App/>,
children: [{
path: 'tasks',
id: 'tasks',
element: <Tasks />,
children: [
{ path: ':taskId', id: 'task' }
]
}]
} as const satisfies ReadonlyDeep<RouteObject>;
The first keywords we came across satisfies And as const it is the foundation upon which all other types rest.
type assertion (type casting) – keyword as
.
interface User {
id: number;
name: string;
}
// any -> User
const typeUser = user as User;
Keyword as const
allows you to convert the value of a variable to the same type
Satisfies
allows you to validate that a value satisfies some type. But it doesn’t lead to it. In the last example, we don’t lose the type as const
but at the same time we check that there is nothing superfluous in the array
Adding the full path to the component to the tree objects
First, let’s look at the auxiliary types that we will need later.
type ConstantRoute<
FullPath extends string,
Path extends string
> = Omit<RouteObject, 'path'> & {
path: CurrentPath;
fullPath: FullPath;
};
type ConcatPath<
Path extends string,
CurrentPath extends string> = `${Path}/${CurrentPath}`;
ConcatPath
connects path segments with a slashConstantRoute
converts two input strings toobject type
with keyspath
,fullPath
where string constants will be
Let’s convert these types to the same JS functions
export const constantRoute = (path: string, fullPath: string): {
path: string;
fullPath: string;
} => ({
path,
fullPath,
})
function concatPath(fullPath: string, path: string) {
return replaceTrailingSlash(`${fullPath}/${path}`);
}
Here I remind you that we have an object with a configuration ROUTES_CONFIG
and the most difficult thing is to convert the object type to the same type with full paths.
export const ROUTES_CONFIG = {
id: 'root',
path: '',
element: <App/>,
children: [{
path: 'tasks',
id: 'tasks',
element: <Tasks />,
children: [
{ path: ':taskId', id: 'task' }
]
}]
} as const satisfies ReadonlyDeep<RouteObject>;
To do this, you need to recursively go through this tree and transform as follows
Was
{
children: [{
path: 'tasks',
id: 'tasks',
children: [{
path: ':taskId',
id: 'task'
}]
}]
}
It became
{
tasks: {
path: 'tasks',
fullPath: 'tasks',
task: {
path: ':taskId',
fullPath: '/tasks/:taskId'
}
}
}
The following types will help us with this.
type MergeArrayOfObjects<T, Path extends string = ''> =
T extends readonly [infer R, ...infer Rest]
? RecursiveValues<R, Path> & MergeArrayOfObjects<Rest, Path>
: unknown;
type RecursiveTransform<
T,
Path extends string = ''
> = /* содержимое типа */
Let’s first analyze MergeArrayOfObjects
which transforms an array of objects
type MergeArrayOfObjects<T, Path extends string = ''> =
T extends readonly [infer R, ...infer Rest]
? RecursiveValues<R, Path> & MergeArrayOfObjects<Rest, Path>
: unknown;
export function mergeArrayOfObjects(arr: RouteObject[], path="") {
if (Array.isArray(arr)) {
return;
}
const [first, ...rest] = arr;
if (first == null) {
return {}
}
return {
...recursiveValues(first, path),
...mergeArrayOfObjects(rest, path),
};
}
Description | TS | JS |
---|---|---|
Rest Infer |
|
|
Recursion termination condition |
|
|
Let’s describe the recursion steps
const routeArr = [
{ id: 'tasks', path: '/tasks' },
{ id: 'home', path: '/home' }
];
expectTypeOf(routeArr).toMatchTypeOf<MergeArrayOfObjects<typeof routeArr>>();
// 1 шаг
T = typeof routeArr
// T extends readonly [infer R, ...infer Rest] === true
R = { id: 'tasks'; path: '/tasks' }
Rest = [{ id: 'home', path: '/home' }]
// R != unknown === true
MergeArrayOfObjects<Rest, Path>
// 2 шаг
T = [{ id: 'home', path: '/home' }]
// T extends readonly [infer R, ...infer Rest] === true
R = { id: 'home'; path: '/home' }
Rest = []
// R != unknown === true
MergeArrayOfObjects<Rest, Path>
// 3 шаг
T = []
// T extends readonly [infer R, ...infer Rest] === true
R = null
Rest = null
// R != unknown === false
// Окончание рекурсии
Let’s analyze the final type
// проверям что объект содержит id и path, извлекаем исходные константы строк
// и трансформируем
// { id: 'tasks', children: [{ id: 'task' }] }
// -> {tasks: { task: {} }}
type RecursiveTransform<
RouteObject,
FullPath extends string = ''
> = RouteObject extends {
id: infer Name extends string;
path: infer Path extends string;
}
? TransformIdToProperty<Name, RouteObject, Path, FullPath>
: { }
type TransformIdToProperty<
ID extends string,
RouteObject,
Path extends string,
FullPath extends string,
// вкратце const = concatPath(fullPath,path), используем параметр вместо переменной
ConcatedPath extends string = ConcatPath<FullPath, Path>
> = {
// проверяем наличие children
[Prop in ID]: RouteObject extends { children: infer Children }
// рекурсивно преобразуем
? MergeArrayOfObjects<Children, ConcatedPath> & ConstantRoute<ConcatedPath, Path>
: ConstantRoute<ConcatedPath, Path>
}
Putting it all together
export const ROUTES_CONFIG = {
id: 'root',
path: '',
element: <App/>,
children: [{
path: 'tasks',
id: 'tasks',
element: <Tasks />,
children: [
{ path: ':taskId', id: 'task' }
]
}]
} as const satisfies ReadonlyDeep<RouteObject>;
type RoutesConfigType = typeof RecursiveTransform;
const transfromRoutes = (config: RouteObject, fullPath="") => {
// transform code
return config as RecursiveTransform<RoutesConfigType>
}
const ROUTES = transfromRoutes(ROUTES_CONFIG);
// ROUTES type and ROUTES object имеют итоговую структуру
ROUTES = {
root: {
tasks: {
path: 'tasks',
fullPath: 'tasks',
task: {
path: ':taskId',
fullPath: '/tasks/:taskId'
}
}
}
}
Now we can use our transformed object
// to='/'
<Link to={ROUTES.root.fullPath}>Home</Link>
// to='/tasks
<Link to={ROUTES.root.tasks.fullPath}>Tasks</Link>
<Link
// to='tasks/1'
to={generatePath(
ROUTES.root.tasks.task.fullPath,
{
taskId: item.id.toString(),
}
)}>
{item.name}
</Link>
As a result, we have learned to use
Congratulations, you’ve made it to the end. You can see the full code in the repository
https://github.com/Mozzarella123/typescript_routing
For those who want to practice writing Typescript types
https://github.com/type-challenges/type-challenges
Thanks
For proofreading and editing