Учимся писать сложные Typescript типы на примере роутинга в React

от автора

Вы используете 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

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
Ограничения соответствуют обычным условиям, в данном случае проверка на то что входной параметр принадлежит множеству строк

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

Path extends ':${infer Param}'

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 объектов

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

Дано:

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

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

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

На выходе:

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

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

Схема преобразования 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

Пример 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

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

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

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


ссылка на оригинал статьи https://habr.com/ru/articles/735542/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *