Привет! Меня зовут Владимир Земсков, я ведущий разработчик, занимаюсь развитием фронтенд-части в 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.
Ключевые моменты
Переход с PropTypes на TypeScript упрощает поддержку типов и устраняет необходимость дублирования описаний.
Исследование существующих решений (ts-morph, react-docgen, react-docgen-typescript) помогло понять их ограничения и выбрать оптимальный путь.
Собственная реализация на основе TypeScript Compiler API дала больше гибкости и контроля над обработкой типов.
Использование ProjectService и Language Service позволило корректно работать с монорепозиторием и сложными типами.
Хотя решение не покрывает все возможные случаи типов, оно демонстрирует, как можно эффективно использовать TypeScript для анализа компонентов и интеграции в существующие инструменты.
Если вы сталкивались с похожими задачами или знаете альтернативные подходы — буду рад обсудить в комментариях! ?