Привет! Я Илья, фронтенд-разработчик в финтех-компании Точка. Нам важно, чтобы поддержка пользователей была на высоком уровне, поэтому у нас есть десятки сервисов для организации обучения специалистов поддержки. Я работаю над одним из таких проектов. Он активно развивается: ежемесячно добавляем более 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-проекте так, чтобы они были централизованными, типизированными и удобными в использовании. Такой подход подойдет быстрорастущим проектам с множеством страниц и параметров: он снижает количество дублирования, делает код предсказуемым и упрощает навигацию по проекту.
И это лишь один из возможных подходов — адаптируйте его под конкретные задачи и стиль вашей команды. Спасибо, что дочитали до конца!
ссылка на оригинал статьи https://habr.com/ru/articles/924208/
Добавить комментарий