Рецепты TypeScript: типизированное преобразование объекта

от автора

Всем привет от ведущего разработчика из Cloud.ru! Как и обещал в предыдущей статье, продолжаю рассказывать про TypeScript-рецепты. Наши рецепты — это готовый код, который можно применить в конкретных ситуациях, а в некоторых случаях и подогнать ситуацию под код.

В этой статье предлагаю обсудить, как на обычном TypeScript приготовить такую функцию, которая из конфига роутинга по приложению вычислит объект, содержащий все пути внутри приложения. При этом сам объект роутинга будет иметь некоторую вложенность, а итоговая мапа с путями будет плоской структурой со значениями полей в виде строк. И всё это строго типизировано!

Задача. Решаем, что и как готовить

Итак, у нас есть некий конфиг с роутингом, который пока никак не типизирован:

const ROUTE_CONFIG = {   url: 'app',   id: 'root',   children: [     {       url: ':user',       id: 'user',       children: [         {           url: 'resource',           id: 'resource',           children: [             { url: ':id', id: 'resourceId' },           ],         },       ],     },     {       url: 'settings',       id: 'settings',     },   ], }

В идеальном мире мы бы хотели, чтобы такой объект был источником правды во всех вопросах внутри приложения — разработчик описал бы конфиг и нигде не дублировал информацию из него.

Давайте представим такую ситуацию: внутри приложения появляются ссылки на его внутренние страницы (знаю, случай редкий, но вдруг 😃). Мы, очевидно, не очень-то хотим каждый раз указывать константную строку, поскольку приложение довольно гибкое.

Более того, мы не хотим заранее задавать строки для каждого роута, чтобы сохранить максимальную гибкость, которую нам дает древовидная структура. Скорее всего, лучшим решением будет построить такую функцию, которая из этого конфига вычислит нам искомую мапу. С учетом нашего примера выше выглядеть она будет примерно так:

const paths = generateRouteMap(ROUTE_CONFIG); // const paths = { //   root: '/', //   user: '/:user', //   resource: '/:user/resource', //   resourceId: '/:user/resource/:id', //   settings: '/settings' // }

В этом и есть наша цель — получить объект, ключами которого будут id рутов, а значениями — полные пути до этих самых страниц. Приступим к самому процессу приготовления.

Составляем рецепт. Прорабатываем алгоритм

Поскольку в TypeScript нет циклов, любой алгоритм, который мы придумываем для вычисления типов, должен быть рекурсивным. В данном случае нужно сделать две вещи:

  • научиться преобразовывать каждую ноду нашего дерева в небольшой объект с ключом — ID и значением — полным путем;

  • добавить некий обработчик, который будет проходить по дочерним ветвям и делать с ними то же самое.

Если тип ExtractConfig преобразовывает одиночную ноду, а ExtractChildren умеет работать с массивом (что само по себе означает работу с рекурсией, ведь нам нужно обойти заранее неизвестное количество элементов), то целевую схему нашей рекурсии можно выразить примерно так:

Давайте попробуем написать первую часть.

Шаг 1. Берем ExtractConfig

Обратите внимание на вторую строку — при помощи оператора in мы «перебираем» поле id и забираем из него его значение, оставляя его в качестве ключа нашего нового объекта:

type ExtractConfig<T extends RouteConfig, Base extends string = ''> =   { [K in T['id']]: `${Base}/${T['url']}` }   & ExtractChildren<T['children'], `${Base}/${T['url']}`>;

После этого мы вызываем ExtractChildren (напишем его чуть ниже), не забывая передавать в качестве базового пути новую строку, чтобы не потерять весь «хвост» при генерации дочерних рутов со своими типами.

Также обратите внимание на строку три — мы используем пересечение для результата, т. к. нам нужно собрать один конечный объект, содержащий все найденные ключи. Поэтому особенно важно внимательно относиться к условию выхода из рекурсии — любое неаккуратное пересечение испортит нам весь тип.

Шаг 2. Добавляем ExtractChildren

Теперь переходим ко второй части — здесь есть сразу несколько интересных моментов:

type ExtractChildren<T, Base extends string = ''>   = T extends [infer Head extends RouteConfig, ...infer Tail]     ? ExtractConfig<Head, Base> & ExtractChildren<Tail, Base>     : Record<string, never>;

Мы рекурсивно перебираем массив, поскольку нам важно сохранить каждый из его элементов в отдельности. Если бы мы попытались работать с юнионом (например, через Array<infer U>), то и на выходе нам пришлось бы его обрабатывать, что немного усложнило бы нам функцию с выводом одного конфига.

Для рекурсивного обхода массива мы вычисляем его первый элемент, а также остальной хвост. Первый элемент мы обрабатываем при помощи ExtractConfig, который написали ранее, и пересекаем его с рекурсивным вызовом для всего остального хвоста. Когда массив опустеет, мы вернем пустой объект, который безопасно пересечется с остальными результатами.

Шаг 3. Готовим конфиг

Вся эта конструкция не будет работать, если не затипизировать изначальный объект конфига. По умолчанию TypeScript предполагает, что любой объект может быть изменен в ходе выполнения программы, поэтому не присваивает для полей объекта конкретные строки. Иными словами, объект {name: "Vasya"} будет автовычислен TypeScript’ом как {name: string}. Для того, чтобы типизация стала конкретной, нам надо сообщить TypeScript’у, что этот объект меняться не будет. Мы можем сделать это после объявления объекта через конструкцию as const.

Однако это приведет к новым проблемам. Поскольку теперь объект имеет собственную константную типизацию, мы можем допустить ошибку при его наполнении. Например, не указать в нужном месте id, что сломает суть нашей функции и не приведет к ошибке при компиляции. При этом мы не можем сделать типизацию привычным способом, например:

type RouteConfig = {   id: string;   url: string;   children?: RouteConfig[]; };  // Все конкретные строки пропадут из типизации, тк мы сами сообщили, // что url это тип string const ROUTE_CONFIG: RouteConfig = {   id: 'app',   url: "app",   children: [...], } as const

На помощь приходит относительная новинка TypeScript — ключевое слово satisfies. Это аналог слова extends в выражениях, но используется для определений.

В нашем случае мы можем сообщить конфигу, что у него может быть любой тип, который расширяет RouteConfig. А поскольку конкретные строки расширяют абстрактные (т. е. тип "app" является наследником типа string), мы сможем сохранить точные значения и не потерять «проверку правописания» при наполнении объекта.

Скопировать весь рецепт

Код с тестовым примером для тех, кто хочет использовать его для собственных кулинарных экспериментов:

type RouteConfig = {   id: string;   url: string;   children?: RouteConfig[]; };  const ROUTE_CONFIG = {   url: 'app',   id: 'root',   children: [     {       url: ':user',       id: 'user',       children: [         {           url: 'resource',           id: 'resource',           children: [             { url: ':id', id: 'resourceId' },           ],         },       ],     },     {       url: 'settings',       id: 'settings',     },   ], } as const satisfies RouteConfig;  type ExtractConfig<T extends RouteConfig, Base extends string = ''> =   { [K in T['id']]: `${Base}/${T['url']}` }   & ExtractChildren<T['children'], `${Base}/${T['url']}`>;  type ExtractChildren<T, Base extends string = ''>   = T extends [infer Head extends RouteConfig, ...infer Tail]     ? ExtractConfig<Head, Base> & ExtractChildren<Tail, Base>     : Record<string, never>;  function generateRouteMap<const T extends RouteConfig>(   config: T,   basePath: string = '', ): ExtractConfig<T> {   const routeMap: Record<string, unknown> = {};    // Построение полного пути   const currentPath = `${basePath}/${config.url}/`;    // Добавление текущего маршрута в объект   routeMap[config.id] = currentPath;    // Если есть дочерние маршруты, рекурсивно обрабатываем их   if (config.children) {     config.children.forEach((child) => Object.assign(       routeMap,       generateRouteMap(child, currentPath),     ));   }    return routeMap as ExtractConfig<T>; }

Спасибо, что дочитали статью до конца. Скоро поделюсь с вами еще одним TypeScript-рецептом — не пропустите.

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


ссылка на оригинал статьи https://habr.com/ru/articles/860770/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *