Вы используете TypeScript, но впадаете в ступор перед, когда видите типы в сторонних библиотеках? Generics, generic constraint, infer, rest infer, conditional и recursive types, satisfies вызывают головную боль? Мы постараемся снизить градус сложности и напишем типы для роутинга в React. Данный материал будет полезен как фронтендерам, так и бекендерам.

Статья предполагает, что вы уже знакомы с TypeScript, знаете основы и используете в повседневной разработке.

Все ключевые слова и концепции TS и роутинга используются в английском варианте. И представлены в формате таблицы:

Описание

TS

JS

План

  • Проблема

  • Инструменты

  • Извлекаем параметры из path

  • Как работает преобразование конфигурации

  • Satisfies, as const, type assertion

  • Добавляем к объектам дерева полный путь до компонента

  • Соединяем все вместе

Проблема

Что такое routing (роутинг)?

В двух словах это система навигации между экранами состоящая из:

  • Screen (экран) - место куда нам нужно попасть, в ui-библиотеках это компоненты.

  • Route (маршрут, роут) - конфигурация маршрута до экрана, может включать в себя path, правила перенаправления и др.

  • Path (путь) - путь, строка по которой формируется URL:

    • Статический /about, /tasks

    • Параметризированный /tasks/:taskId

  • URL - конечный адрес сформированный согласно path http://tutorial/tasks/1

*Эти термины будут использоваться далее*


// 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);

В Single Page приложениях роутинг производится на стороне клиента. И в большинстве React приложений, с которыми я работал:

  1. Cистема роутинга разбросана по файлам

// Tasks.tsx
function Tasks() {
  return (
    <Task path=":taskId" />
  )
}
// App.tsx
function App() {
  return (
    <Router>
	  <Tasks path="tasks" />
    <Router>
  )
}
  1. Навигация по приложению осуществляется с помощью текстовых констант в свойствах компонента to={'/path/to/component}

Даже в примере из документации самой популярной библиотеки react-router, ссылки на экраны пишутся так:

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>
    </>
  );
}

Приложение разрастается и количество роутов увеличивается. Наступает момент когда требуется изменить навигацию и приходится вручную искать файлы где используется этот роут и менять вручную.

Но path’ы не всегда встречаются полной строкой /tasks/:taskId, а могут собираться из разных переменных:

const tasksPath = 'tasks';
const { taskId } = useParams()
generateUrl(`${tasksPath}/:taskid, { taskId }`)

Поэтому зачастую при рефакторинге можно что-то пропустить. В приложении появляются битые ссылки и пользователи негодуют.

К чему мы хотим прийти

В этом туториале мы научимся писать сложные TypeScript типы на примере централизованного роутинга.

В интернете можно найти TypeScript-first routing библиотеку type-route, мы будем писать с нуля. К тому же наше решение универсально и работает с любой библиотекой роутинга.

Что получится в итоге

Итоговый формат конфигурации роутинга
Валидация и пример использования
  • Дерево всех роутов приложения в одном месте — json конфиг;

  • Возможность получать эти роуты и видеть подсказки в IDE;

  • Генерацию итогового URL по path»у;

  • Валидацию параметров при генерации URL.

Инструменты

Что мы будем использовать помимо самого TS

  • react — библиотека рендеринга;

  • type‑fest — набор ts утилит;

  • expect‑type — тестирование типов;

  • react‑router — библиотека роутинга для React.

Извлекаем параметры из path

В react-router уже есть типы для этой задачи, но мы конечно-же изобретем велосипед.
Для этого нам понадобится следующие типы:

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; }

Эти два типа — основа для валидации и IDE suggestions параметров при генерации URL на основе path:

Валидация
IDE-suggestions

ExtractParam

Напомню, что path — это строка /segment/:parameterSegment/segement2 по которому генерируется итоговый URL. Состоит из следующих сегментов:

  • :parameterSegment — динамический параметр, который заменяется на конкретное значение в URL;

  • segment — неизменяемая часть;

  • / — разделяющий слеш.

Разберем первый тип ExtractParam. Он преобразует строку сегмента с параметром в object type с таким же ключом

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'>>();
});

Для облегчения понимания работы переведем тип ExtractParam в псевдокод на JS.

(* Я не утверждаю, что под капотом оно работает именно так)
(** Данный подход я позаимствовал из библиотеки
type‑type, она позволяет писать сложные типы в JS‑like нотации)

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({ });
});

В таблице представлены эквиваленты всем ключевым словам и концепциям:

Концепт

TS

JS

Property mapping

{
[Param]: string;
}

{
[param]: ''
}

GENERICS
Обобщенные типы - обычные функции принимающие на вход параметр

type ExtractParam<Path>

const extractParam = (path: any)

GENERICS CONSTRAINTS
Ограничения соответствуют обычным условиям, в данном случае проверка на то что входной параметр принадлежит множеству строк

Path extends ':${infer Param}'

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

CONDITIONAL TYPES
Условные типы соответствуют обычному if-else блоку, либо тернарному оператору

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

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

INFER
соответствует извлечению исходного типа в данном случае остальной части после символа :
* Может использоваться только в generic constraints
** Если TS не может вывести тип указанный после ключевого слова, то он возвращает unknown

Path extends ':${infer Param}'

const param = path.slice(1);

ExtractParams

Тип ExtractParams преобразует строку path в объект где ключи это сегменты с параметрами, а значения тип 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({ });
});

Заметим, что здесь используются Recursive types . Если вспомнить как устроены рекурсивные функции, то выглядит примерно так:

  • Объявление функции

  • Условие прекращения рекурсии

  • Рекурсивный вызов

Описание

TS

JS

Объявление

type ExtractParams<Path>

const extractParams

Условие прекращения рекурсии

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

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

Рекурсивный вызов

ExtractParam<Segment> & ExtractParams<Rest>

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

Как работает преобразование конфигурации

Формат преобразования

В react‑router можно использовать для дерева роутинга простой json объект.

Для построения дерева используется массив children.

Но проблема в том, что обращение по индексу children[0].fullPath — невозможно использовать.

Поэтому нужно преобразовать массивы children в дерево объектов и добавить полный путь до компонента:

Схема преобразования JS объектов

Дано:

  • интерфейс конфигурации конкретного роута из react-router;

  • конфигурация роутинга на основе этого интерфейса;

  • функция трансформирующая исходную конфигурацию в нужный нам вид.

На выходе:

Мы получаем финальный объект который нам позволяет извлекать пути следующим образомROUTES.tasks.task.fullPath = /tasks/:taskId

Схема преобразования TS типов

С типами нужно проделать примерно то же самое: к исходному интерфейсу RouteObject из react-router добавить fullPath с полным путем до экрана и заменить path как обычную строку, на path где будет константная строка из конфигурации:

path: ':taskId'
fullPath: '/tasks/:taskId'

Satisfies, as const, type assertion

// Исходная конфигурация
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>;

Первые ключевые слова которые нам встретились satisfies и as const это фундамент на котором держатся все остальные типы.

Type assertion (приведение типов) — ключевое слово as.

interface User {
  id: number;
  name: string;
}
// any -> User
const typeUser = user as User;

Ключевое слово as const позволяет преобразовать значение переменной в такой же тип

Пример as const и satisfies

Satisfies позволяет валидировать, что значение удовлетворяет какому-то типу. Но не приводит к нему. В последнем примере мы не теряем тип as const но в тоже время проверяем чтобы в массиве не было ничего лишнего.

Добавляем к объектам дерева полный путь до компонента

Для начала разберем вспомогательные типы, которые нам понадобятся позже:

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 соединяет сегменты path c помощью слеша

  • ConstantRoute преобразует две входных строки в object type с ключами path, fullPath где будут лежать константы строк

Преобразуем эти типы в такие же JS функции

export const constantRoute = (path: string, fullPath: string): {
  path: string;
  fullPath: string;
} => ({
  path,
  fullPath,
})

function concatPath(fullPath: string, path: string) {
  return replaceTrailingSlash(`${fullPath}/${path}`);
}

Здесь напомню, что у нас есть объект с конфигурацией ROUTES_CONFIG и самое сложное преобразовать тип объекта в такой же тип с полными путями.

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>;

Для этого нужно рекурсивно пройти по этому дереву и преобразовать следующим образом

Было:

{
  children: [{
    path: 'tasks',
    id: 'tasks',
    children: [{ 
        path: ':taskId', 
        id: 'task' 
    }]
  }]
}

Стало:

{
  tasks: {
    path: 'tasks',
    fullPath: 'tasks',
    task: {
      path: ':taskId',
      fullPath: '/tasks/:taskId'
    }
  }
}

В этом нам помогут следующие типы:

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 = ''
> = /* содержимое типа */

Первым разберем MergeArrayOfObjects , который преобразует массив объектов:

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),
  };
}

Описание

TS

JS

Rest Infer
Работает он также как и оператор spread

[infer R, ...infer Rest]

const [first, ...rest] = arr

Условие прекращения рекурсии

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

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

Опишем шаги рекурсии:

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
// Окончание рекурсии

Разберем финальный тип:


// проверям что объект содержит 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>
}

Соединяем все вместе

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'
      }
    }
  }
}

Теперь мы можем использовать наш преобразованный объект:

// 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>

В итоге мы научились использовать

Поздравляю вы дошли до конца. Полный код, вы можете посмотреть в репозитории:

https://github.com/Mozzarella123/typescript_routing

Для тех, кто хочет потренироваться в написании Typescript типов:

https://github.com/type-challenges/type-challenges

Благодарности

За вычитку и редактуру

Ссылки и материалы