Привет! Меня зовут Владимир Земсков, я ведущий разработчик, занимаюсь развитием фронтенд-части в low code платформе билайна. В статье расскажу, как мы решили отказаться от PropTypes в пользу TypeScript для автоматического извлечения типов пропсов React-компонентов. 

Наши разработчики давно просили эту возможность, справедливо возмущаясь: «Зачем описывать типы дважды — в TypeScript и PropTypes?». Тем более, что аналогичный механизм уже работал в Storybook.

Статья будет полезна:

  • Разработчикам, которые хотят автоматизировать извлечение типов из React-компонентов.

  • Создателям low-code платформ, UI-китов или систем документации, которые ищут альтернативы ручному описанию пропсов.

  • Всем, кто интересуется TypeScript Compiler API и статическим анализом кода для решения подобных задач.

Если вы недовольны текущими решениями для организации библиотек компонентов или просто любите технические кейсы — добро пожаловать под кат!

Поиск решения: первый этап

Сначала я обратился к знакомому мне инструменту ts-morh. Раньше я уже использовал его для рефакторинга. Например, когда в общем компоненте поменялся публичный интерфейс, и нужно изменить поведение во всем проекте, или вы решили применить фича-флаг и убрать его использование.

Эта библиотека написана поверх API-компилятора TypeScript и позволяет чуть проще с ним взаимодействовать. На самом деле, все ее возможности можно реализовать при помощи самого компилятора.

Встал вопрос — как же мне осуществить навигацию по типам? Если для ast есть встроенные инструменты, то в навигации типов становится уже все на так легко. Отчасти это связано с тем, что система типов позволяет создавать очень разнообразные варианты. Например, запустить doom.

Для понимания работы с навигацией по типам очень помог этот gist. В целом, с помощью него удалось написать первый proof of concept, который подтверждал, что задача решаема и будет работать за приемлемое время.

Поиск решения: второй этап

Вторым этапом стоило понять, как существующие решения решают эту проблему. Признаться честно, до этой задачи я использовал TypeScript только для описания типов в своих проектах и особо не заботился о том, как он работает. У меня были общие представления о работе с ast, которые я получил во время написания плагинов к eslint. Хотелось получить больше представления о работе с компилятором TypeScript — лучше всего это делать в уже готовых проектах, поэтому я пошел на гитхаб и стал их искать.

Посмотрев несколько проектов, я решил, что самый для меня понятный — это Storybook. Думаю, многим фронтенд-разработчикам он знаком. Давайте попробуем разобраться, как же он получает информацию о типах.

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

При сборке проекта в поле “__docgenInfo” записывается информация, полученная при помощи  react-docgen-typescript и react-docgen. Когда я разрабатывал решение, в коде использовались обе эти библиотеки. А когда я пишу эту статью, при проверки оказалось, что используется только react-docgen-typescript.

В чем же принципиальная разница между двумя этими решениями? 

React-docgen-typescript использует TypeScript-компилятор для определения типов, а react-docgen бабель. Конечно, за счет того, что react-docgen работает только в контексте одного файла, его скорость значительно превосходить react-docgen-typescript. Однако данные, которые можно получить из типа значительно меньше.

Я попытался использовать react-docgen-typescript для решения своей задачи, но мне не хватило возможности кастомизации — хотелось больше контроля над данными, которые получают из типов. А еще эта библиотека некорректно резолвила типы из нашего монорепозитория (спойлер: для решения этой проблемы использовался ProjectService).

Поиск решения: третий этап

Третий этап — это написание собственного решения. Я посмотрел на работы нескольких библиотек и решил, что стоит использовать TypeScript-compiler. В ts-morh оказалось слишком много возможностей для редактирования, сохранения кода, которые я использовать не собирался, а готовой библиотеки, которая удовлетворяла бы всем запросом, я не нашел.

К сожалению, документация к нему устарела и не поддерживается, хотя общее представление о работе дает. Надеюсь, в новой версии 7 будет получше. А пока пришлось опираться на код самого typescript. Все-таки удобно, что можно понять, что к чему в самом компиляторе, зная typescript. Также помог опыт анализа типов при помощи ts-morh. И, конечно же, код react-docgen-typescript помог понять, как получить тип непосредственно props в React-компоненте.

Итак, у меня есть функция createLibrary, которая принимает React-компоненты и путь до вызова этой функции. Давайте попробуем без погружения в детали рассмотреть решение, которое получилось. 

Для начала нам надо создать проект TypeScript. Делается он просто: находим файл конфига при помощи ts.findConfigFile и создаем ProjectService:

 const service = new tsserver.server.ProjectService({
   host: system,
   cancellationToken: { isCancellationRequested: (): boolean => false },
   useSingleInferredProject: false,
   useInferredProjectPerProjectRoot: false,
   session: undefined,
   logger,
   canUseWatchEvents: true,
 });

 service.setCompilerOptionsForInferredProjects(
   configFile.options as ts.server.protocol.InferredProjectCompilerOptions,
 );

Далее нам потребуется ts.Program

const languageService = service.getLanguageService();
const program = languageService.getProgram();

Уже при помощи Program, мы может открыть наш файл с вызовом createLibrary и получить тип компонентов, которые мы передаем.

Пройдемся по каждому и вытащим props. Тут я использовал похожий на react-docgen-typescript подход.

Дальше нам нужно разобрать тип. Тут и пригодился опыт использования ts-morh. Кстати, в самом ts-morh есть резолв различных примтивов — например, boolean. Большинство из них опирается на наличие флагов у типов. Самое главное при рекурсивном обходе ограничить глубину и ширину типов, иначе какой-нибудь HtmlDivElement может подвесить ваш процесс.

Теперь можно все собрать и получить результат, который ожидаешь. Конечно, решить все возможные варианты типов у меня не получилось. Да и для определения в нашей платформе нужного контрола они все и не требуется. 

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

type OverrideFieldType<OriginalType, Field extends FieldTypeValues> = 
  OriginalType & { __discriminator?: { field: Field;} };

Так я смог отдать нашим разработчикам новый подход к описанию контролов при помощи typescript.

Ключевые моменты

  1. Переход с PropTypes на TypeScript упрощает поддержку типов и устраняет необходимость дублирования описаний.

  2. Исследование существующих решений (ts-morph, react-docgen, react-docgen-typescript) помогло понять их ограничения и выбрать оптимальный путь.

  3. Собственная реализация на основе TypeScript Compiler API дала больше гибкости и контроля над обработкой типов.

  4. Использование ProjectService и Language Service позволило корректно работать с монорепозиторием и сложными типами.

Хотя решение не покрывает все возможные случаи типов, оно демонстрирует, как можно эффективно использовать TypeScript для анализа компонентов и интеграции в существующие инструменты. 

Если вы сталкивались с похожими задачами или знаете альтернативные подходы — буду рад обсудить в комментариях! ?

Комментарии (0)