Привет! Я Илья, фронтенд-разработчик в финтех-компании Точка. Нам важно, чтобы поддержка пользователей была на высоком уровне, поэтому у нас есть десятки сервисов для организации обучения специалистов поддержки. Я работаю над одним из таких проектов. Он активно развивается: ежемесячно добавляем более 10 новых страниц — сейчас в проекте их больше 120.
В статье расскажу, как мы поэтапно организовали хранение путей роутера и связали параметры страниц с компонентами их вёрстки. Такой подход помогает повысить читаемость кода, сокращает его дублирование и упрощает поддержку.
Примеры в статье написаны на TypeScript, React и React Router, но подход универсален и может применяться в других технологиях:
React, Next.js, Vue, Svelte (Frontend).
React Native (Mobile).
Electron (Desktop).
Node.js, NestJS, Express (Backend).
Частая проблема организации путей роутинга

export const MAIN_PAGE_PATH = '/';
export const AUTH_PAGE_PATH = '/login';
export const NOT_FOUND_PAGE_PATH = '/404';
export const SEARCH_PAGE_PATH = '/search';
export const TASKS_PAGE_PATH = '/tasks';
export const ACTUAL_TASKS_PAGE_PATH = '/tasks/actual';
export const FINISHED_TASKS_PAGE_PATH = '/tasks/finished';
export const getTaskPagePath = (id: number, type: string): string => `/tasks/${id}?type=${type}`;
// и это хорошо ещё, если конструктор параметров вынесен глобально
// ...и здесь ещё сто штук
Обычно маршруты страниц в проектах выглядят именно так.
Эти многочисленные константы при вёрстке используются в таком формате:
--------- home-page.tsx:
import { useNavigate } from 'react-router-dom';
// используем утилиты для навигации
const navigate = useNavigate();
// осуществляем переход на другую страницу, прокидываем параметры
navigate(getTaskPagePath(task.id, task.type))
--------- task-page.tsx:
import { useParams } from 'react-router';
import { useQueryParams } from 'shared/helpers';
// извлекаем параметры страницы
const { id } = useParams<{ id: number }>();
const { type } = useQueryParams<{ type: string }>();
// как-то используем id и type в верстке
...
Поддерживать и развивать такой код непросто. Среди очевидных проблем:
Поиск нужного пути затруднён однотипностью констант. При большом количестве похожих переменных легко запутаться и потратить лишнее время на поиск нужной.
Дублирование в нейминге. В проекте могут использоваться константы с суффиксами PAGE, PATH, ROUTE и т.п. Это усложняет чтение и поддержку кода.
Отсутствие связи между параметрами и страницами. Path- и query-параметры определены и в константах путей, и в самих компонентах страниц. В результате дублирования типов увеличивается вероятность несоответствий и ошибок.
Когда маршрутов становится больше, эти сложности накапливаются и негативно влияют на масштабируемость.
Базовое решение проблемы
В своем проекте мы решили создать единый объект, в котором ключи имеют полное соответствие строковому пути, кроме двух служебных:
index для корневых путей;
item для динамических путей элементов, обычно по идентификаторам.
Теперь все маршруты хранятся в таком виде:
export const Routes = {
index: '/',
login: '/login',
notFound: '/404',
search: '/search',
tasks: {
index: '/tasks',
actual: '/tasks/actual',
finished: '/tasks/finished',
item: (id: number, type: string) => `/tasks/${id}?type=${type}`
},
}
А навигация выглядит так:
const navigate = useNavigate();
...
navigate(Routes.tasks.item(123))
Это довольно простое и быстрое решение, и теперь у нас:
единая точка выбора пути, а автокомплит IDE быстро поможет найти нужный;
все пути чётко структурированы, легко ориентироваться и добавлять новые;
нет дублирования лишних слов в нейминге констант.
Не обязательно следовать именно нашей структуре: соблюдать точный нейминг ключей, юзать служебные index/item или единый объект — это лишь договорённость наших разработчиков. Главное — выбрать удобную и понятную схему для вашей команды.
Параметризуем маршруты
Но это было только начало. После этого мы решили раскрыть потенциал TypeScript.

Идея в том, чтобы связать параметры страниц и объект путей Routes.
Есть два типа параметров:
Path: /example/:id
Query: /example?id=123
Давайте сперва подготовим прототип получения параметров на страницах. Было бы классно просто передать в специальную утилиту наш путь или его тип из Routes, а в ответ получить параметры. Тогда все типы мы смогли бы описать единоразово в Routes. На примере React-хука:
const { id, type } = usePageParams<typeof Routes.tasks.item>();
Разберем пошагово, как этого добиться:
1. Сперва параметризуем пути в Routes.
Для проброса параметров будем использовать функции такого вида:
export const Routes = {
...
tasks: {
item: (id: number, type: string) => `/tasks/${id}?type=${type}`
}
}
А чтобы упростить генерацию таких функций, создадим вспомогательную утилиту, которая:
будет принимать путь в качестве аргумента (если path-параметров нет — строку, а если есть — функцию).
будет возвращать функцию генерации пути с внедрением path и query-параметров:
Также для удобства разделим path и query на разные объекты и введём простенький хелпер createUrl, который будет склеивать ориджин, путь, квери и хэш в итоговый URL.
import { stringify } from 'qs';
export type TParametrizedRoute<TPath = any, TQuery = any> = (params: {
path?: TPath;
query?: TQuery;
}) => string;
// Утилита генератор функций:
// - принимаем путь в виде строки или функции
// - возвращаем функцию, которая конструирует маршрут страницы с параметрами
function parametrizedRoute<TPath = undefined, TQuery = undefined>(
pathOrGetPath: TPath extends undefined ? string : (path: TPath) => string
): TParametrizedRoute<TPath, TQuery> {
return ({ path, query }: { path?: TPath; query?: TQuery }) => {
return createUrl({
path: typeof pathOrGetPath === 'function'
? pathOrGetPath(path!)
: pathOrGetPath || '',
query
});
};
}
// В статье приводится примитивная универсальная утилита для склейки частей URLа.
// Пример готовой библиотеки: https://github.com/meabed/build-url-ts
export const createUrl = (args: {
origin?: string;
path?: string;
query?: Record<string, unknown>;
hash?: string;
}): string => {
return [
args.origin || '',
args.path || '',
args.query ? `?${stringify(args.query)}` : '',
args.hash ? `#${args.hash}` : ''
].join('');
};
Используем новую утилиту для создания путей, есть четыре возможных сценария:
export const Routes = {
tasks: {
// у страницы есть только query - передаём строку пути:
index: parametrizedRoute<undefined, { userId?: number; type?: string }>(
'/tasks'
)
// у страницы есть только path:
item: parametrizedRoute<{ id: number }>((params) => {
return `/tasks/${params.id}`;
}),
// у страницы есть и path, и query:
item: parametrizedRoute<{ id: number }, { type?: string }>((params) => {
return `/tasks/${params.id}`;
}),
// у страницы нет ни query, ни path:
list: '/tasks/list'
}
};
2. Теперь напишем типы для извлечения параметров:
// Тип параметризованного пути уже ввели ранее:
export type TParametrizedRoute<TPath = any, TQuery = any> = (params: {
path?: TPath;
query?: TQuery;
}) => string;
// Поскольку все конечные значения параметров в URL'е превратятся в строки, то лучше привести к ним, для этого используем специальный тип-конвертер:
export type TObjectFieldsToStrings<T> = {
[K in keyof T]: T[K] extends string | undefined ? T[K] : string;
};
// Утилита извлечет и преобразует нужное поле из аргумента параметризованного пути
type TExtractRouteParams<
T extends TParametrizedRoute,
K extends keyof Parameters<T>[0]
> = TObjectFieldsToStrings<
NonNullable<Parameters<T>[0][K]>
>;
// Утилита извлекает path из параметризованного пути
export type TExtractPageParams<T extends TParametrizedRoute>
= TExtractRouteParams<T, 'path'>;
// Утилита извлекает query из параметризованного пути
export type TExtractPageQuery<T extends TParametrizedRoute>
= TExtractRouteParams<T, 'query'>;
3. И утилиты для извлечения параметров:
import { stringify } from 'qs';
import { useCallback, useMemo } from 'react';
import { useParams } from 'react-router';
import { URLSearchParamsInit, useSearchParams } from 'react-router-dom';
/**
* Утилита для извлечения параметров страницы (из path + из query)
*/
export function usePageParams<T extends TParametrizedRoute>(): {
path: TExtractPageParams<T>;
query: Partial<TExtractPageQuery<T>>;
setParams: ReturnType<typeof useQueryParams<TExtractPageQuery<T>>>[1];
} {
const path = useParams() as TExtractPageParams<T>;
const [query, setParams] = useQueryParams<TExtractPageQuery<T>>();
return { path, query, setParams };
}
/**
* Утилита для работы с query-параметрами, если её нет во фреймворке
*/
export function useQueryParams<T = Record<string, unknown>>(
defaultValues?: URLSearchParamsInit
): [Partial<T>, (values: Partial<T>, replace?: boolean) => void] {
const [value, setValue] = useSearchParams(defaultValues);
const parsedQueryParams = useMemo(
() => Object.fromEntries(value.entries()) as Partial<T>,
[value]
);
const setQueryParams = useCallback(
(values: Partial<T>, replace: boolean = true) => {
setValue(stringify({ ...parsedQueryParams, ...values }), { replace });
},
[parsedQueryParams]
);
return [parsedQueryParams, setQueryParams];
}
4. Итоговое получение параметров на странице будет выглядеть так:
export const TaskPage = (): ReactElement => {
const params = usePageParams<typeof Routes.tasks.item>();
const { path: { id }, query: { type } } = params;
return ( ... );
}
Готово! А автокомплит в IDE успешно подсказывает существующие параметры при их извлечении.
Улучшаем схему: автоматическое извлечение параметров из строк
А что, если избежать ручного указания path-параметров? Ведь обычно они уже содержатся в пути страницы. И этот же строковый путь передаётся в Router:
{
path: '/tasks/:id',
element: <TaskPage />
}
Для начала напишем упрощённую версию. Мы хотим передать строку пути и автоматически извлечь из неё параметры. Воспользуемся мощными возможностями языка TypeScript: используем infer и Template Literal Types.
/**
* Тип, который разбирает строку и извлекает параметры
*/
type TExtractParams<T extends string> =
// Первый случай: строка содержит `:param/что-то`
T extends `${string}:${infer Param}/${infer Rest}`
// записываем тип "строка" по имени параметра
// и рекурсивно обрабатываем оставшуюся часть строки
? { [K in Param]: string } & TExtractParams<`/${Rest}`>
: // Второй случай: строка содержит `:param` без слэшей
T extends `${string}:${infer Param}`
? { [K in Param]: string }
: unknown;
const example: TExtractParams2<'/tasks/:first/:second'> = {
first: '123', // Автокомплит IDE работает
second: '456',
};
Теперь нам нужно доработать уже известный parametrizedRoute, чтобы он принимал путь в качестве параметра и возвращал всё так же функцию для генерации пути. Для этого воспользуемся магией литералов в TypeScript.

А если формально, то используем вывод типа из строкового литерала с помощью конструкции <T extends string>(route: T).
const Routes = {
tasks: {
item: parametrizedRoute('/tasks/:id')
}
};
// Мы не задаём тип вручную через generic, а наоборот:
// он сам извлекается из аргумента, и мы можем использовать его далее
function parametrizedRoute<T extends string>(route: T): TParametrizedRoute<T> {
const preparePath = (params: TExtractParams<T>): string => {
// TODO: замена параметров в пути на реальные значения (сделаем далее)
return '';
};
return ({ path }: { path: TExtractParams<T> }): string => {
return createUrl({ path: preparePath(path) });
};
}
// Проверяем:
navigate(
Routes.tasks.item({
path: {
id: '123' // Автокомплит работает
}
})
);
Но здесь есть важный нюанс: нам ведь нужны ещё и query-параметры.
А если мы добавим их вторым аргументом, то магия литералов выключится и будем вынуждены пробрасывать путь в качестве дженерика, вот так:
function parametrizedRoute<P extends string, Q extends Record<string, unknown>>(
path: P,
query: Q
): TParametrizedRoute<P, Q> {
...
}
const Routes = {
tasks: { ↓↓↓ дублирование
item: parametrizedRoute<'/tasks/:id', { type: string }>('/tasks/:id')
}
};

Но такое дублирование кода нам совсем ни к чему.
Тогда нужно оставить у parametrizedRoute единственный generic-тип. А query привяжем с помощью цепочки вызовов там, где это нужно:
const Routes = {
tasks: {
// пример без query:
itemNoQuery: parametrizedRoute('/tasks/:id'),
// пример с query:
itemWithQuery: parametrizedRoute('/tasks/:id').withQuery<{
type: number;
}>()
}
};
Доработаем утилиты и посмотрим на весь код:
/**
* Тип, который разбирает строку и извлекает параметры
*/
export type TExtractParams<T extends string>
= T extends `${string}:${infer Param}/${infer Rest}`
? { [K in Param]: string } & TExtractParams<`/${Rest}`>
: T extends `${string}:${infer Param}`
? { [K in Param]: string }
: unknown;
/**
* Итоговый тип параметризированного пути:
* - функция с аргументом path-параметров.
* - поле-функция withQuery, которая возвращает функцию с аргументами path и query-параметров.
* - поле route в каждой функции с исходным роутом (например, для React Router'а)
*/
type TParametrizedRoute<T extends string> = {
({ path }: { path: TExtractParams<T> }): string;
route: string;
withQuery<TQuery extends Record<string, unknown>>(): {
({ path, query }: { path: TExtractParams<T>; query: TQuery }): string;
route: string;
};
};
/**
* Утилита для создания параметризованного пути.
* Значения path и query-параметров автоматически подставляются
* в путь страницы при конечном вызове
*/
function parametrizedRoute<T extends string>(route: T): TParametrizedRoute<T> {
const preparePath = (params: TExtractParams<T>): string => {
let processed: string = route;
Object.keys(params).forEach((key) => {
const value = params[key];
if (typeof value === 'string') {
processed = processed.replace(`:${key}`, value);
}
});
return processed;
};
const result = ({ path }: { path: TExtractParams<T> }): string => {
return createUrl({ path: preparePath(path) });
};
result.route = route;
result.withQuery = <TQuery extends Record<string, unknown>>() => {
const fn = ({ path, query }: { path: TExtractParams<T>; query: TQuery }): string => {
return createUrl({ path: preparePath(path), query });
};
fn.route = route;
return fn;
};
return result;
}
/**
* Утилита для извлечения параметров страницы (из path + из query)
*/
function usePageParams<T extends (args: any) => string>(_route: T) {
type RouteParams = Parameters<T>[0];
type P = RouteParams extends { path: infer Path } ? Path : never;
type Q = RouteParams extends { query: infer Query } ? Query : never;
const path = useParams() as P;
const [query, setParams] = useQueryParams<Q>();
return { path, query, setParams };
}
Обратите внимание, что использование usePageParams изменилось, теперь мы будем передавать роут не через тип, а в аргументы:
const {
path: { taskId },
query: { type },
} = usePageParams(Routes.tasks.item);
Проверяем, что всё работает и IDE подсказывает типы правильно:
export const useExample = (): void => {
const navigate = useNavigate();
// ВСЁ ОК
navigate(
Routes.tasks.itemWithQuery({
path: {
id: '123'
},
query: {
type: 456
}
})
);
// ВСЁ ОК
const withQuery = usePageParams(Routes.tasks.itemWithQuery);
console.log(withQuery.path.id); // '123' - string!
console.log(withQuery.query.type); // 456
console.log(withQuery.route); // 'tasks/:id'
// ВСЁ ОК
navigate(
Routes.tasks.itemNoQuery({
path: {
id: 123
}
})
);
// ВСЁ ОК
const noQuery = usePageParams(Routes.tasks.itemNoQuery);
console.log(noQuery.path.id); // 123 - number!
console.log(noQuery.route); // 'tasks/:id'
};
С помощью полей "route" объект Routes можно использовать как единый источник правды при создании роутера. Это исключает расхождения и упрощает поддержку:
export const browserRouter = createBrowserRouter([
{
children: [
{
path: Routes.tasks.index.route,
element: <div>INDEX</div>
},
{
path: Routes.tasks.item.route,
element: <div>ITEM</div>
}
]
}
]);
Хм, а ЕЩЁ улучшить можно как-то?

Типизируем автоматические path-параметры
Не всегда path-параметры — это только строки или только числа. Давайте сделаем так, чтобы можно было указывать тип параметров:
const Routes = {
tasks: {
itemString: parametrizedRoute('/tasks/:id<string>'),
itemNumber: parametrizedRoute('/tasks/:id<number>'),
itemCustom: parametrizedRoute('/tasks/:id<custom>').withQuery<...>(),
}
};
«А что за custom такой?», — спросите вы. Это мы так внедряем гибкость возможных типов: например, для поддержки строковых литеральных объединений, когда параметр может принимать только одно из фиксированных значений.
И ещё обработаем корнер-кейс, когда есть только query-параметры. Для этого напишем утилиту TRemoveEmptyObjects, которая преобразует пустой объект path: {} на необязательное поле path?: never.
parametrizedRoute('/example').withQuery<{ test: number }>()
…
type TRemoveEmptyObjects<T> = {
[K in keyof T as keyof T[K] extends never ? never : K]: T[K];
};
type TPathParams<T extends string> = TRemoveEmptyObjects<{ path: TExtractParams<T> }>;
Новый план такой:
Парсим строку роута:
извлекаем path-параметры.из “скобочек” <...>
извлекаем строковый тип: “string” / “number” / “custom” / … .
Преобразовываем строковый тип в настоящий TS-тип: string / number / “a” | “b” / … .
На JS подставляем path-параметры (/:id), преобразуя в указанный тип.
Чтобы не просто писать код, а сразу его проверять и наглядно видеть то, что делают типы – давайте также дописывать тесты на каждый тип. Мы подсмотрели, как писать тесты, в статье у Кости Логиновских.
Итоговый код с комментариями:
/*
* Мапы преобразования строки в фактический тип
*/
type TDefaultType = number;
type TParamTypeMap = {
string: string;
number: number;
custom: 'a' | 'b';
};
type TTypeConverters = {
[K in keyof TParamTypeMap]: (v: string | undefined) => TParamTypeMap[K] | undefined;
};
export const TYPE_CONVERTERS: TTypeConverters = {
string: (v) => String(v),
number: (v) => (isNaN(Number(v)) ? undefined : Number(v)),
custom: (v) => (v === 'a' ? 'a' : 'b'),
};
export const getParamRegExp = (key: string): RegExp => new RegExp(`:${key}(?:<([\\w]+)>)?`);
/**
* Тип, который скопирует переданный тип и тем самым объединит объекты в один
* {id: number} & {type: string} => { id: number; type: string }.
* Это визуально улучшит ошибки в IDE
* @see https://www.totaltypescript.com/concepts/the-prettify-helper
*/
type TPrettify<T> = {
[K in keyof T]: T[K];
} & {};
type TPrettifyTests = [
Expect<
Equals<
TPrettify<{ first: string } & { second: { third: number } }>,
{ first: string; second: { third: number } }
>
>,
];
/**
* Тип, который определяет тип path-параметра
*/
type TGetParamType<Param extends string> =
// Проверяем, содержит ли строка имя и тип
Param extends `${infer NamePart}<${infer TypePart}>`
? // Проверяем, указан ли такой тип в мапе
TypePart extends keyof TParamTypeMap
? // Тип указан в мапе -> берём тип из мапы
{ [K in NamePart]: TParamTypeMap[TypePart] }
: // Тип не указан в мапе -> дефолтный
{ [K in NamePart]: TDefaultType }
: // Тип "<type>" не указан -> дефолтный
{ [K in Param]: TDefaultType };
type TGetParamTypeTests = [
Expect<Equals<TGetParamType<'param<string>'>, { param: string }>>,
Expect<Equals<TGetParamType<'param<number>'>, { param: number }>>,
Expect<Equals<TGetParamType<'param<custom>'>, { param: 'a' | 'b' }>>,
];
/**
* Тип, который разбирает строку и извлекает параметры с учётом <type>
*/
export type TExtractParams<T extends string> = TPrettify<
T extends `${string}:${infer Param}/${infer Rest}`
? TGetParamType<Param> & TExtractParams<`/${Rest}`>
: T extends `${string}:${infer Param}`
? TGetParamType<Param>
: unknown
>;
type TExtractParamsTests = [
Expect<Equals<TExtractParams<'/:first'>, { first: TDefaultType }>>,
Expect<Equals<TExtractParams<'/start/:first<string>/end'>, { first: string }>>,
Expect<
Equals<
TExtractParams<'/start/:first<number>/middle/:second/end'>,
{ first: number; second: TDefaultType }
>
>,
Expect<
Equals<
TExtractParams<'/:first<number>/:second/:third<custom>'>,
{ first: number; second: TDefaultType; third: 'a' | 'b' }
>
>,
];
/**
* Обрабатываем случай, когда только квери: parametrizedRoute('/example').withQuery<{ test: number }>(),
* Пустой объект { path: {} } будет преобразован в необязательное поле { path?: never }
*/
type TRemoveEmptyObjects<T> = {
[K in keyof T as keyof T[K] extends never ? never : K]: T[K];
};
type TRemoveEmptyObjectsTests = [
Expect<
Equals<
TRemoveEmptyObjects<{ first: {}; second: { example: string }; third: {} }>,
{ second: { example: string } }
>
>,
];
/**
* Тип, который извлекает и преобразовывает path-параметры при их наличии
*/
type TPathParams<T extends string> = TRemoveEmptyObjects<{ path: TExtractParams<T> }>;
type TPathParamsTests = [
Expect<Equals<TPathParams<'/:first<string>'>, { path: { first: string } }>>,
Expect<Equals<TPathParams<'/example'>, {}>>,
];
/**
* Тип, который преобразует все НЕ строки в строки. Нужен для Query.
*/
type TObjectFieldsToStrings<T> = {
[K in keyof T]: T[K] extends string | undefined ? T[K] : string;
};
type TObjectFieldsToStringsTests = [
Expect<
Equals<
TObjectFieldsToStrings<{ first: number; second: boolean; third: string }>,
{ first: string; second: string; third: string }
>
>,
];
/**
* Итоговый тип параметризированного пути:
*/
export type TParametrizedRoute<T extends string> = {
// здесь теперь TPathParams, который убирает пустой path
(params: TPathParams<T>): string;
// исходный путь вида /example/:id<number>/inner
initialRoute: T;
// путь для роутера вида /example/:id/inner
route: string;
withQuery<TQuery extends Record<string, unknown>>(): {
// здесь теперь TPathParams, который убирает пустой path
(params: TPathParams<T> & { query: TQuery }): string;
initialRoute: T;
route: string;
};
};
export function parametrizedRoute<T extends string>(route: T): TParametrizedRoute<T> {
const preparePath = (params: TPathParams<T>): string => {
if (!('path' in params)) {
return route;
}
let processed: string = route;
const path = params.path as TExtractParams<T>;
Object.keys(path).forEach((key) => {
processed = processed.replace(getParamRegExp(key), String(path[key]));
});
return processed;
};
function prepareRoute(value: string): string {
return value.replace(/:(\w+)<[^>]+>/g, ':$1');
}
const result = (params: TPathParams<T>): string => {
return createUrl({ path: preparePath(params) });
};
result.initialRoute = route;
result.route = prepareRoute(route);
result.withQuery = <TQuery extends Record<string, unknown>>() => {
const fn = (params: TPathParams<T> & { query: TQuery }): string => {
return createUrl({ path: preparePath(params), query: params.query });
};
fn.initialRoute = route;
fn.route = prepareRoute(route);
return fn;
};
return result;
}
/**
* Утилита для извлечения параметров страницы (из path + из query)
*/
export function usePageParams<
Route extends string,
Fn extends ((args: any) => string) & { initialRoute: Route },
>(fn: Fn) {
type RouteParams = Parameters<Fn>[0];
type P = RouteParams extends { path: infer Path } ? Path : never;
// Квери параметры возвращаются именно строками - преобразуем:
type Q = RouteParams extends { query: infer Query } ? TObjectFieldsToStrings<Query> : never;
const rawPathParams = useParams();
/**
* Преобразовываем значения параметров к ожидаемым типам
*/
const path = useMemo(() => {
return Object.fromEntries(
Object.keys(rawPathParams).map((key) => {
const value = rawPathParams[key];
const match = fn.initialRoute.match(getParamRegExp(key));
const type = match?.[1];
const defaultConverter: (v: any) => TDefaultType | undefined = TYPE_CONVERTERS['number'];
const converter: (v: string | undefined) => any =
(type && TYPE_CONVERTERS[type]) ?? defaultConverter;
return [key, converter?.(value)];
}),
) as P;
}, [rawPathParams, fn.initialRoute]);
const [query, setQuery] = useQueryParams<Q>();
return { path, query, setQuery };
}
Итоги
Мы рассмотрели, как можно организовать маршруты страниц в TypeScript-проекте так, чтобы они были централизованными, типизированными и удобными в использовании. Такой подход подойдет быстрорастущим проектам с множеством страниц и параметров: он снижает количество дублирования, делает код предсказуемым и упрощает навигацию по проекту.
И это лишь один из возможных подходов — адаптируйте его под конкретные задачи и стиль вашей команды. Спасибо, что дочитали до конца!