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

  1. Routing system scattered across files

// Tasks.tsx
function Tasks() {
  return (
    <Task path=":taskId" />
  )
}
// App.tsx
function App() {
  return (
    <Router>
	  <Tasks path="tasks" />
    <Router>
  )
}
  1. 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/:taskIdbut 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

Final routing configuration format

Final routing configuration format

Validation and use case

Validation and use case

  • 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

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

Validation

Validation

IDE suggestions

IDE suggestions

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 URL

  • segment – 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

{
[Param]: string;
}

{
[param]: ''
}

GENERICS
Generic types – regular functions that take a parameter as input

type ExtractParam<Path>

const extractParam = (path: any)

GENERICS CONSTRAINTS
Constraints correspond to the usual conditions, in this case checking that the input parameter belongs to a set of strings

if (typeof path === "string" && path.startsWith(':'))

Path extends ':${infer Param}'

CONDITIONAL TYPES
Conditional types correspond to a regular if-else block, or a ternary operator

Path extends ':${infer Param}'
? {
[Param]: string;
}
: {};

if (condition) {
}
else {
return {}
}

INFER
corresponds to extracting the original type in this case the rest after the character :
* Can only be used in generic constraints
** If TS cannot infer the type specified after the keyword, then it returns unknown

Path extends ':${infer Param}'

const param = path.slice(1);

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

type ExtractParams<Path>

const extractParams

Recursion termination condition

Path extends ‘${infer Segment}/${infer Rest}’

const firstSlash = path.indexOf('/');
if (firstSlash === -1) {
return extractParam(path);
}

Recursive call

ExtractParam<Segment> & ExtractParams<Rest>

{
...extractParam(segment),
...extractParams(rest)
}

How Configuration Conversion Works

Conversion Format

Conversion Format

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

Scheme for transforming JS objects

Scheme for transforming JS objects

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

TS Type Conversion Scheme

TS Type Conversion Scheme

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

as const and satisfies example

as const and satisfies example

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 slash

  • ConstantRoute converts two input strings to object type with keys path, 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
It works just like the spread operator.

[infer R, ...infer Rest]

const [first, ...rest] = arr

Recursion termination condition

T extends readonly [infer R, ...infer Rest]

if (first == null) {
return {}
}

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

Links and materials

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *