Рецепты TypeScript: подстановка параметров в путь
Хабр, привет! Это Костя Логиновских — ведущий разработчик в Cloud.ru. Этой статьей я начинаю цикл коротких материалов, посвященный рецептам TypeScript. Что такое рецепты? Это готовый код, который можно применить в конкретных ситуациях, а в некоторых случаях и подогнать ситуацию под код.
Наше первое блюдо — функция на обычном TypeScript, которая поможет вычислить необходимые параметры для строки с маской постановки (например, :userId/resources/:resourceId
, где такие параметры — это userId
и resourceId
) и заставит пользователя указать эти параметры либо выдаст ошибку при сборке проекта.
Постановка задачи или что будем готовить
В некоторых проектах мы используем react-query — очень удобный инструмент для кеширования запросов в мапу, где в качестве ключей выступают специальные массивы. Чаще всего они состоят из двух элементов — ключа и параметров.
В нашей команде мы приняли такое правило, что ключ состоит из двух параметров — постоянной и переменной части, причем постоянная часть — это url, по которому осуществляется запрос. Чтобы эта часть действительно была постоянной, часть этого url мы используем с подстановками, т. е. меняем в ней параметры на [:parameterName]
.
Чтобы не дублировать строковые константы, эти постоянные пути записаны в специальные переменные, которые мы используем как для построения ключа, так и для формирования самого запроса. Вот пример:
import { generatePath } from '/path/to/module';
const resourceUrl = ':user/resource/:id';
generatePath(resourceUrl, {});
// Здесь TypeScript должен вывести ошибку
// В типе "{}" отсутствуют следующие свойства из типа "{ user: string; id: string; }": user, id
Приготовим функцию generatePath по рецепту ниже.
Шаг 1. Вычисляем один параметр
Для начала представим, что у нас есть только одна строка с одним параметром или без него. Нам понадобится утилитарный тип с дженериком, который будет определять, начинается ли строка с символа »:» , и если да, то возвращать остальную строку. В противном случае мы сможем просто вернут never
, что важно и очень удобно:
type ExtractPath = T extends :${infer Param} ? Param : never;
Как видим, с этой задачей прекрасно справляется ключевое слово infer
, которое превращает наше условное выражение в подобие математического уравнения. Мы можем представить это выражение так: «Скажи нам, TypeScript, существует ли такой тип Param
, с помощью которого можно выразить тип T
, который мы передали?». Если существует, мы записываем его в переменную с соответствующим именем и можем смело использовать в левой части условного выражения. Если нет — переходим в правую. Если же решений несколько, TypeScript возьмет наиболее точное из возможных (самое близкое по наследованию).
Теперь, если передать в качестве T :user
, то наш самописный тип в ответ вернет тип user
(не путать со строкой), а при передаче строки без двоеточия вернет never
.
Давайте найдем все такие вхождения в строке со множеством сегментов, чтобы наше блюдо получилось вкуснее.
Шаг 2. Разбиваем строку
В TypeScript отсутствуют циклы, поэтому любой алгоритм, который вы примените, будет рекурсивным — это единственная возможность обходить заранее неизвестные итеративные значения. Все, что нам нужно для рекурсии — это признак для деления и условие выхода, если этого признака нет.
В нашей задаче мы используем разбиение строки по слешу, поскольку именно он делит url на части, а отсутствие слеша будем считать концом рекурсии:
// Находим в строке слеш и делим строку на две части, записывая из в Left & Right
type ExtractPaths = T extends `${infer Left}/${infer Right}`
// И левую и правую части отправляем дальше по рекурсии
? ExtractPaths | ExtractPaths
// Если слеша нет, пользуемся только что написанной утилитой
: ExtractPath;
Обратите внимание, что в четвертой строке мы использовали юнион для объединения всех результатов всех вызовов. Для нас это означает, что строка будет делиться до тех пор, пока в ней не останется ни одного слеша. Затем ExtractPath
, который мы написали до этого, достанет из этой строки нужный нам тип — так все эти типы в конечном итоге попадут в один большой юнион.
Например, строка :user/resource/:id
будет разбита на :user
, resource
и :id
. После прогона через экстрактор всё превратится в "user" | never | "id"
, а затем компилятор приведет ее к "user" | "id"
.
Если бы внутри ExtractPath
мы возвращали не never, а что-нибудь другое, такого легкого объединения бы не получилось. Дело в том, что при создании юниона TypeScript стремится объединить некоторые типы по признаку их наследования. Скажем, если вы попытаетесь создать юнион из конкретной и абстрактной строки ("user" | string
), то TypeScript приведет это все к одной абстрактной строке — она покрывает собой сразу все кейсы. Так и в нашем случае — never является наследником всех типов, но от него самого ничего не наследуется. Поэтому при попадании в юнион он просто игнорируется как бессмысленный тип, ведь любой другой тип его уже расширяет.
Шаг 3. Используем Prettify — вишенка на торте
В конечный результат я добавил только создание нового объекта из юниона (строка восемь), — мы можем сделать это встроенным способом:
type ExtractPath = T extends `:${infer Param}` ? Param : never;
type ExtractPaths =
T extends `${infer Left}/${infer Right}`
? ExtractPaths | ExtractPaths
: ExtractPath;
type PathParams = Prettify, string>>;
function generatePath(path: T, params: PathParams): string {}
А еще добавил маленькую приятную обертку, без которой сообщения об ошибке смотрелись не очень комфортно — Prettify
. Вот так:
type Prettify = T extends Record ? {
[K in keyof T]: Prettify;
} : T;
По сути, этот дженерик создает копию созданного типа, но отвязывает его от реализации. В нашем случае ошибка типизации без него выглядела бы примерно так: Аргумент типа "{}" нельзя назначить параметру типа "PathParams<":user/resource/:id">"
.
Но если добавить Prettify
, ошибка становится приятнее: Аргумент типа "{}" нельзя назначить параметру типа "{ user: string; id: string; }"
.
Сохраняем рецепт
А вот и полный рецепт — сохраняйте и готовьте нужные блюда с учетом вашей кухни:
type Prettify = T extends Record ? {
[K in keyof T]: Prettify;
} : T;
type ExtractPath = T extends `:${infer Param}` ? Param : never;
type ExtractPaths =
T extends `${infer Left}/${infer Right}`
? ExtractPaths | ExtractPaths
: ExtractPath;
type PathParams = Prettify, string>>;
function generatePath(path: T, params: PathParams) {
const keys = Object.keys(params) as ExtractPaths[];
return keys.reduce((acc, key) => acc.replace(new RegExp(`:${key}(/|$)`, 'gi'), `${params[key]}$1`), path as string);
}
На этом всё. Спасибо, что дочитали до конца. Если было интересно, не пропустите рассказы о других наших кулинарных шедеврах, которыми я планирую делиться в течение этой недели.
Интересное в блоге: