Как перевести приложение с Flow на TypeScript
Привет! Меня зовут Олег, я работаю в роли фронтенд-тимлида в команде разработки приложения — розничного кредитного конвейера Газпромбанка. Я подготовил гайд о том, как перейти с FlowJS на TypeScript.
Какие проблемы были с Flow?
Сразу отмечу, что речь идёт о Flow версии 0.78 — возможно, многое из того, что я опишу далее, было решено.
Проблема потери типов замыкания. Предположим, у нас есть некоторая функция. Мы ее протипизировали, прописали, что она возвращает number (то есть какой-то timestamp) с отсечкой в час. Далее, если мы вернем её из какой-то внешней функции и запишем в переменную, значение этой переменной будет иметь тип any.
Решалось это с помощью «костылей»: в первом случае мы могли протипизировать саму переменную (в которую мы сохраняем результат) как number, и она становилась number. Во втором — протипизировать замыкание, так, мы дублировали типизацию самой функции, но при этом получали результирующий тип при вызове этой функции
Недостаточный охват в NPM. Внешние пакеты редко содержат в себе Flow declaration файлы, из-за это приходится использовать автогенератор типов flow-typed. На выходе мы опять получаем типы any и, по сути, никакой типизации.
Проблема mixed типов. Наверное, об этом на профильных форумах не писал только ленивый. Самый распространённый способ получить эту проблему заключается в переборе объекта по value.
// Даже протипизировав все значения ключей объекта как number
let numObject: { [k: string]: number };
// Object.values приведет их к mixed и просуммировать значения не получится без описания guard'а
Object.values(numObject).reduce((acc, cur) => acc + cur, 0);
Потеря безопасной проверки в Callback. В чём суть: если мы сначала делали проверку if на optional тип или на null, а затем ниже по коду пытались обратиться к этой же переменной внутри коллбэка (например map«а или filter«а), то снова возникала необходимость проверки на undefined или null.
let someData: number | null;
if (!someData) {
return;
}
sampleArray.map((el: number): number => {
// flow снова требует проверку
if (someData) {
return el + someData;
}
// также придется описать код, который никогда не исполнится, так как коллбэк требует возвращения значения
return el;
});
// вне коллбэка проверка, указанная выше сохраняется
const newVal = someData + 1;
Переход на новую версию flow давал более трех тысяч ошибок в типизации. Их исправление по трудоёмкости соразмерно было переходу на новые способы статической типизации.
Почему TypeScript?
Главное — уже на версии 3.8, которая была в тот момент, он решал описанные выше проблемы.
Кроме того, у нас появилась автогенерация типов для DTO. Мы используем в проекте Svagger на бэкэнде, и с помощью библиотеки json-schema-to-typescript автоматизировали процесс описания типов DTO-данных, ускорив разработку в разы. На выходе в данном случае получается файл TS с набором интерфейсов, используемых на бэкэнде, который можно обновить в любой момент.
В TypeScript есть ENUM — очень мощный инструмент. Он является и данными, и типами одновременно. Благодаря ENUM очень быстро и удобно можно определять модели для Form Values в форме и для Validation Results — буквально в пару строчек.
Пример, как быстро описать типы полей формы и результатов валидации этой формы:
enum ECreditFormString {
FirstName = 'firstName',
LastName = 'lastName',
// ... еще поля
}
enum ECreditFormNumber {
Age = 'age',
// ... еще поля
}
type TCreditForm = {
[P in ECreditFormString]: string;
} & {
[P in ECreditFormNumber]: number;
};
type TCreditFormValidationResults = {
[P in keyof TCreditForm]?: string | null;
};
Наконец, мы стали использовать продвинутые конструкции TS, которые позволяют быстро описывать сложную правильную типизацию, избегать «костылей», которые были на Flow. Например, быстрый перебор типа в другой с помощью конструкции in keyof из примера выше.
Как реализовать переход в TypeScript?
Шаг 1. Настройка окружения. Необходимо установить сам TypeScript и библиотеки типов, которые используются в проектах, описать TS Config и настроить ESLint. Для настройки ESLint можно просто описать массив overwrite для файлов типа JS и JSX — установить для них соответствующие парсы, рулы и настройки, затем сделать то же самое для TS и TSX файлов.
Шаг 2. Настройка Jest. Надеюсь, многие из вас используют юнит-тесты. Если так, то наверняка вы сталкивались с проблемой мокирования функций, когда один файл экспортирует несколько функций. Из-за модульной системы ES моки навешиваются не на исходные функции в вашем коде, а на их копии в файле с тестами, в результате чего счетчики не отрабатывают для исходного кода, и моки, по своей сути, не работают. Модульная система typescript«а решает эту проблему по умолчанию, соответственно писать юнит-тесты в файлах .ts сразу же становится удобнее.
Для настройки jest-config необходимо дополнить модуль fileExtensions с новыми решениями файлов, .ts и .tsx, добавить библиотеку ts-jest для .ts-файлов, также не забудьте добавить расширения .ts, .tsx в transformPatterns. Так Jest будет работать корректно и для .js, и для .ts файлов.
Шаг 3. Самое важное: перевод кода и смена расширения файлов. Поскольку процесс растянут во времени, нам необходимо обеспечить возможность постепенного перехода, то есть сделать так, чтобы типизация сохранялась в каждый момент времени — на каждом коммите. Если мы будем просто переводить JS файлы в TypeScript, а методы TypeScript — в JS, будут получаться данные с типом any и мы потеряем типизацию
На каждый момент времени на каждом коммите наша типизация в приложении должна быть не хуже, чем в момент времени после коммита.
Также здесь необходимо обеспечить параллельную работу, чтобы как можно большее число разработчиков могли одновременно переводить приложение.
Разбор процесса переноса модулей
Предположим, у нас есть два модуля: Customer и Product. Модуль Product будет отправной точкой к тому, чтобы начать перевод с FlowJS на TS.
Входным файлом в нем является компонент Products (products.jsx).
Здесь обратите внимание, что Products содержит компонент ProductList, который использует в себе компонент Product (product.tsx), находящийся в директории components, и описан в TypeScript.
Мы пытаемся его импортировать в JavaScript. В данном случае он импортируется через индекс-файл —, но индекс у нас тоже TS. Соответственно, этот Product имеет тип any для Flow. Чтобы этого избежать, нужно в компонентах создать новый JavaScript-файл, назвать его index.js, аналогично импортируемому файлу, и добавить расширение Flow (таким образом мы получим декларативный файл index.js.flow).
Здесь мы описываем, что это файл Flow.
В самом файле необходимо дописать тип для компонента Product и его пропсов, чтобы он был распознан в нашем .js-файле. Для этого мы используем ключевое слово declare, пишем, что это var Product и вместо реализации указываем только тип. В данном случае это будет ComponentType библиотеки React (также можно выставлять тип FC или Component). Теперь в ProductList компоненте, компонент Product будет протипизирован как react-компонент.
Здесь важно отметить, что index.js.flow-файл, который мы создали — это не исполняемый файл, исполняемым файлом остался index.ts.
То есть import { Product } забирает исполняемые данные не из index.js.flow, а из index.ts. Затем Flow находит аналогичное имя — index — с расширением .js.flow и забирает типы. Таким образом, компонент Product становится протипизированным для product-list.jsx файла, хотя его исполняемый код находится в .tsx файле.
Давайте посмотрим на другой пример. Наш компонент Product использует компонент ProductActionBar из файла product-action-bar.jsx. То есть теперь, наоборот, в файл, описанный на TS мы импортируем данные из файла, описанного на Flow. Без дополнительной доработки все данные из этого файла с точки зрения TS будут описаны как any.
Чтобы избежать этой проблемы, мы здесь в компонентах добавляем новый TypeScript-файл, называем его product-action-bar, и ставим расширение .d.ts — декларативный в понимании TypeScript.
Теперь, когда мы будем импортировать комопнент ProductActionBar в product.ts файл из product-action-bar.jsx, мы, по аналогии с предыдущим примером, также импортируем из исполняемые файлы, но уже из .jsx, а TypeScript будет искать в каталоге аналогичное название файла, но с расширением .d.ts, и подставит типы для TypeScript. Тем самым ProductActionBar станет протипизированным для исполняемого файла product-action-bar.jsx.
Часто у разработчиков возникает вопрос, особенно когда они используют IDE: «А что сюда импортировать: .jsx или .d.ts?» На самом деле, нет никакой разницы, если в Babel правильно настроено расширение: компилятор сам поймет, что из какого файла брать. Если у нас расширение файла .js, .jsx, .ts, .tsx, то это всегда исполняемый файл, если у нас расширение .d.ts или .js.flow — это всегда декларативный файл.
Теперь, допустим, customers.tsx использует в себе комопнент Products. При этом компонент из модуля products естественно импортируется из индекса модуля — products/index.js.
Поскольку customer.tsx написан на TS, то нам надо добавить декларацию для индекса модуля products — products/index.d.ts. Далее, по аналогии с предыдущими примерами, необходимо описать декларацию для компонента Products в файле index.d.ts.
Теперь мы всегда можем импортировать компонент Products и в .js, и в .ts файлы без потери типа данных.
Вопросы и сложности которые могут возникнуть
Далее отвечу на пару вопросов, которые часто возникают при переносе приложений на Type Script.
Являются ли файлы с расширением.js.flow и с расширением .d.ts временными? На каком этапе перехода можно их удалять?
d.ts и .js.flow — это декларативные файлы, которые нужны для того чтобы сохранять типы данных. Когда мы переходим от Flow к TypeScript на каком-то этапе появятся модули или страницы, которые уже полностью переведены. Однако на этом этапе удалять .js.flow файлы нельзя, потому что мы ещё не знаем, в каком модуле нам может потребоваться тип под Flow. Как только не останется ни одного исполняемого JS файла, мы удалим все .js.flow, потому что Flow больше в коде не осталось.
С d.ts история точно такая же. В момент, когда приложение полностью состоит из .ts и .tsx-файлов, мы все .d.ts и все .js.flow удаляем. Единственное исключение — global.d.ts (файл с глобальными типами, не требующими импортирования) так и останется.
Допустим, есть проект на 95% на Flowи стоит задача переписать его на TS. Он состоит из констант, из модулей, изстраниц, из компонентов и так далее. С чего лучше начать?
Как показывает практика, лучший подход — сначала переписать модули, которые ничего в себя не импортируют и сами являются источниками. Затем —браться за страницы, потому что сложно рассматривать отдельный модуль вне их контекста. Параллельно переписываем несамостоятельные модули. И, наконец, всякие константы и остальное.
Компоненты надо определить в три категории: части страницы, части модуля, используются во всем приложении. Первые две категории переводим на TS вместе с их модулем или страницей. Третью категорию вообще лучше вынести в отдельную private npm библиотеку.
Итак, мы рассмотрели процесс перевода приложения с FlowType на TypeScript. Если у вас остались вопросы или вы уже занимались переходом, и в процессе возникли трудности — пишите в комментариях, разберёмся вместе!