Рецепты TypeScript: подстановка параметров в путь

8c88dd56bfd08ceef07bcedfaf74f73c

Хабр, привет! Это Костя Логиновских — ведущий разработчик в 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);
}

На этом всё. Спасибо, что дочитали до конца. Если было интересно, не пропустите рассказы о других наших кулинарных шедеврах, которыми я планирую делиться в течение этой недели.

Интересное в блоге:

© Habrahabr.ru