Учимся писать сложные 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 приложений, с которыми я работал:
Cистема роутинга разбросана по файлам
// Tasks.tsx
function Tasks() {
return (
<Task path=":taskId" />
)
}
// App.tsx
function App() {
return (
<Router>
<Tasks path="tasks" />
<Router>
)
}
Навигация по приложению осуществляется с помощью текстовых констант в свойствах компонента
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:
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 |
|
|
GENERICS |
|
|
GENERICS CONSTRAINTS |
|
|
CONDITIONAL TYPES |
|
|
INFER |
|
|
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 |
---|---|---|
Объявление |
|
|
Условие прекращения рекурсии |
|
|
Рекурсивный вызов |
|
|
Как работает преобразование конфигурации
В react‑router можно использовать для дерева роутинга простой json объект.
Для построения дерева используется массив children.
Но проблема в том, что обращение по индексу children[0].fullPath
— невозможно использовать.
Поэтому нужно преобразовать массивы children в дерево объектов и добавить полный путь до компонента:
Дано:
интерфейс конфигурации конкретного роута из react-router;
конфигурация роутинга на основе этого интерфейса;
функция трансформирующая исходную конфигурацию в нужный нам вид.
На выходе:
Мы получаем финальный объект который нам позволяет извлекать пути следующим образомROUTES.tasks.task.fullPath
= /tasks/:taskId
С типами нужно проделать примерно то же самое: к исходному интерфейсу 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
позволяет преобразовать значение переменной в такой же тип
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 |
|
|
Условие прекращения рекурсии |
|
|
Опишем шаги рекурсии:
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>
В итоге мы научились использовать
Generics (https://www.typescriptlang.org/docs/handbook/2/generics.html)
Generic constraints (https://www.typescriptlang.org/docs/handbook/2/generics.html#generic-constraints)
Conditional Types (https://www.typescriptlang.org/docs/handbook/2/conditional-types.html)
Infer /Rest infer (https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-8.html#type-inference-in-conditional-types)
Recursive types (https://www.typescriptlang.org/play#example/recursive-type-references)
Satisfies (https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-9.html#the-satisfies-operator)
As const (https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-4.html#const-assertions)
Написали типизированный роутинг
Поздравляю вы дошли до конца. Полный код, вы можете посмотреть в репозитории:
https://github.com/Mozzarella123/typescript_routing
Для тех, кто хочет потренироваться в написании Typescript типов:
https://github.com/type-challenges/type-challenges
Благодарности
За вычитку и редактуру
Ссылки и материалы
Роутинг библиотека react-router (https://reactrouter.com/en/main)
Роутинг библиотека type-route (https://type-route.zilch.dev/)
Typescript утилиты type-fest (https://github.com/sindresorhus/type-fest)
Документавция React (https://react.dev/)
Документация Typescript
Практика (https://github.com/type-challenges/type-challenges)
JS-like написание типов (https://github.com/mistlog/typetype)
Бонус: ПарсингJSON схемы с помощью infer (https://alexharri.com/blog/build-schema-language-with-infer)