Переносим 30 000 строк кода с Flow на TypeScript +21


Недавно мы перенесли 30 тысяч строк кода JavaScript нашей системы MemSQL Studio с Flow на TypeScript. В этой статье я расскажу, почему мы портировали кодовую базу, как это происходило и что получилось.

Дисклеймер: моя цель — вовсе не критика Flow. Я восхищаюсь проектом и думаю, что в сообществе JavaScript достаточно места для обоих вариантов проверки типов. В итоге каждый выберет то, что ему лучше подходит. Искренне надеюсь, что статья поможет в этом выборе.

Сначала введу вас в курс дела. Мы в MemSQL большие фанаты статической и строгой типизации JavaScript, чтобы избежать типичных проблем с динамической и слабой типизаций.

Речь о распространённых проблемах:

  1. Ошибки типа в рантайме из-за того, что различные части кода не согласованы по неявным типам.
  2. Слишком много времени тратится на написание тестов для таких тривиальных вещей, как проверка параметров типов (проверка в рантайме ещё и увеличивает размер пакета).
  3. Не хватает интеграции редактора/IDE, потому что без статической типиизации гораздо сложнее реализовать функцию Jump to Definition (Перейти к определению), механический рефакторинг и другие функции.
  4. Нет возможность писать код вокруг моделей данных, то есть сначала спроектировать типы данных, а затем код в основном «пишет себя».

Это лишь некоторые преимущества статической типизации, ещё больше перечислено в недавней статье о Flow.

В начале 2016 года мы внедрили tcomb для реализации некоторой типобезопасности в рантайме одного из наших внутренних проектов JavaScript (дисклеймер: я не занимался этим проектом). Хотя проверка во время выполнения иногда полезна, она даже и близко не даёт всех преимуществ статической типизации (сочетание статической типизации с проверкой типов в рантайме может подойти для определённых случаев, io-ts позволяет сделать это с помощью tcomb и TypeScript, хотя я никогда не пробовал). Понимая это, мы решили внедрить Flow для другого проекта, который начали в 2016 году. В то время Flow казался отличным выбором:

  • Поддержка компании Facebook, проделавшей удивительную работу по развитию React и росту сообщества (они ещё и разработали React с помощью Flow).
  • Примерно та же экосистема JavaScript-разработки. Было страшновато отказаться от Babel для tsc (компилятор TypeScript), потому что мы теряли гибкость перехода на другую проверку типов (очевидно, с тех пор ситуация изменилась).
  • Не нужно типизировать всю кодовую базу (мы хотели получить представление о статически типизированном JavaScript, прежде чем идти ва-банк), а только часть файлов. Обратите внимание, что сейчас это позволяют и Flow, и TypeScript.
  • TypeScript (в то время) не хватало некоторых основных функций, которые теперь есть, это типы lookup, параметры по умолчанию для обобщённых типов и др.

Когда мы в конце 2017 года начали работать над MemSQL Studio, то собирались покрыть типами всё приложение (оно целиком написано на JavaScript: и фронтенд, и бэкенд выполняются в браузере). Мы взяли Flow как инструмент, который успешно использовали в прошлом.

Но моё внимание привлёк Babel 7 с поддержкой TypeScript. Этот релиз означал, что переход на TypeScript больше не требует перехода на всю экосистему TypeScript и можно продолжать использовать Babel для JavaScript. Что ещё более важно, мы могли использовать TypeScript только для проверки типов, а не как полноценный «язык».

Лично я считаю, что отделение проверки типов от генератора кода — более элегантный способ статической (и сильной) типизации в JavaScript, потому что:

  1. Мы разделяем проблемы кода и типизации. Это уменьшает остановки на проверку типов и ускоряет разработку: если по какой-то причине проверка типов идёт медленно, код всё равно правильно сгенерируется (если вы используете tsc с Babel, то можете настроить его на такое же поведение).
  2. У Babel отличные плагины и функции, которых нет у генератора TypeScript. Например, Babel позволяет указать поддерживаемые браузеры и автоматически выдаст код для них. Это очень сложная функция и нет смысла параллельно поддерживать её в двух разных проектах.
  3. Мне нравится JavaScript как язык программирования (кроме отсутствия статической типизации), и я понятия не имею, сколько будет существовать TypeScript, в то время как верю в долгие годы ECMAScript. Поэтому предпочитаю писать и «думать» на JavaScript (обратите внимание, что я говорю «использовать Flow» или «использовать TypeScript» вместо «писать на Flow» или «на TypeScript», потому что всегда представляю их инструментами, а не языками программирования).

Конечно, у такого подхода есть некоторые недостатки:

  1. Компилятор TypeScript теоретически может выполнять оптимизации на основе типов, а здесь мы лишаемся такой возможности.
  2. Конфигурация проекта немного усложняется при увеличении количества инструментов и зависимостей. Думаю, это относительно слабый аргумент: нас связка Babel и Flow ни разу не подводила.

TypeScript как альтернатива Flow


Я заметил растущий интерес к TypeScript в сообществе JavaScript: и в онлайне, и у окружающих разработчиков. Поэтому как только узнал, что Babel 7 поддерживает TypeScript, сразу начал изучать потенциальные варианты перехода. Кроме того, мы столкнулись с некоторыми недостатками Flow:

  1. Более низкое качество интеграции редактора/IDE (по сравнению с TypeScript). Nuclide — собственная IDE от Facebook с наилучшей интеграцией — уже устарела.
  2. Меньшее сообщество, а значит меньше определений типов для различных библиотек, и они более низкого качества (на данный момент у репозитория DefinitelyTyped 19 682 звезды GitHub, а у репозитория flow-typed только 3070).
  3. Отсутствие публичного плана развития и слабое взаимодействие между командой Flow в Facebook и сообществом. Можете прочитать этот комментарий сотрудника Facebook, чтобы понять ситуацию.
  4. Большое потребление памяти и частые утечки — у некоторых наших разработчиков Flow иногда занимал почти 10 ГБ оперативной памяти.

Конечно, следовало изучить, насколько нам подходит TypeScript. Это очень сложный вопрос: изучение темы включало тщательное чтение документации, которая помогла понять, что для каждой функции Flow есть эквивалент TypeScript. Затем я исследовал общедоступный план развития TypeScript, и мне очень понравились функции, которые запланированы на будущее (например, частичное выведение аргументов типов, что мы использовали во Flow).

Перенос более 30 тыс. строк кода с Flow на TypeScript


Для начала следовало обновиться Babel с 6 до 7. Эта простая задача заняла 16 человеко-часов, поскольку мы решили одновременно обновить Webpack 3 до 4. Задачу усложнили некоторые устаревшие зависимости в нашем коде. У подавляющего большинства JavaScript-проектов таких проблем не будет.

После этого мы заменили набор настроек Flow от Babel на новый набор настроек TypeScript, а затем в первый раз запустили компилятор TypeScript на всех наших исходниках, написанных с помощью Flow. Результат — 8245 синтаксических ошибок (tsc CLI не показывает реальные ошибки для проекта, пока не исправлены все синтаксические ошибки).

Сначала это число нас напугало (очень), но мы быстро поняли, что большинство ошибок связано с тем, что TypeScript не поддерживает файлы .js. Изучив тему, я узнал, что файлы TypeScript должны заканчиваться либо .ts, либо .tsx (если в них есть JSX). Мне это кажется явным неудобством. Чтобы не думать о наличии/отсутствии JSX, я просто переименовал все файлы в .tsx.

Осталось около 4000 синтаксических ошибок. Большинство из них связаны с типом import, который с помощью TypeScript можно заменить просто на import, а также отличием в обозначении объектов ({||} вместо {}). Быстро применив пару регулярных выражений, мы оставили 414 синтаксических ошибок. Всё остальное пришлось исправлять вручную:

  • Тип existential, который мы используем для частичного выведения аргументов обобщённого типа, следовало заменить явными аргументами или типом unknown, чтобы сообщить TypeScript о неважности некоторых аргументов.
  • У типа $Keys и других продвинутых типов Flow другой синтаксис в TypeScript (например, $Shape“” соответствует Partial“” в TypeScript).

После исправления всех синтаксических ошибок tsc, наконец, сказал, сколько реальных ошибок типов в нашей кодовой базе — всего около 1300. Теперь следовало сесть и решить, стоит продолжать или нет. В конце концов, если миграция займёт недели, то лучше остаться на Flow. Однако мы решили, что перенос кода потребует менее одной недели работы одного инженера, что вполне приемлемо.

Обратите внимание, что на время миграции пришлось остановить всю работу над этой кодовой базой. Тем не менее, параллельно можно начинать новые проекты — но придётся иметь в виду потенциально сотни ошибок типов в существующем коде, что непросто.

Что за ошибки?


TypeScript и Flow во многих отношениях по-разному обрабатывают код JavaScript. Так, Flow более строг в отношении одних вещей, а TypeScript — в отношении других. Глубокое сравнение двух систем будет очень длинным, поэтому просто изучим некоторые примеры.

Примечание: все ссылки на песочницу TypeScript предполагают «строгие» параметры. К сожалению, когда вы делитесь ссылкой, эти параметры не сохраняются в URL-адресе. Поэтому их необходимо задать вручную после открытия любой ссылки на песочницу из этой статьи.

invariant.js


Очень распространённой в нашем исходном коде оказалась функция invariant. Просто процитирую документацию:

var invariant = require('invariant');

invariant(someTruthyVal, 'This will not throw');
// No errors

invariant(someFalseyVal, 'This will throw an error with this message');
// Error raised: Invariant Violation: This will throw an error with this message

Идея понятна: простая функция, которая выдаёт ошибку по какому-то условию. Посмотрим, как реализовать и использовать её на Flow:

type Maybe<T> = T | void;

function invariant(condition: boolean, message: string) {
  if (!condition) {
    throw new Error(message);
  }
}

function f(x: Maybe<number>, c: number) {
  if (c > 0) {
    invariant(x !== undefined, "When c is positive, x should never be undefined");

    (x + 1); // works because x has been refined to "number"
  }
}

Теперь загрузим тот же фрагмент в TypeScript. Как видите по ссылке, TypeScript выдаёт ошибку, поскольку не может понять, что x гарантированно не останется undefined после последней строки. Это на самом деле известная проблема — TypeScript (пока) не умеет делать такое выведение через функцию. Однако это очень распространённый шаблон в нашей кодовой базе, поэтому пришлось вручную заменить каждый экземпляр invariant (более 150 штук) на другой код, который сразу выдаёт ошибку:

type Maybe<T> = T | void;

function f(x: Maybe<number>, c: number) {
  if (c > 0) {
    if (x === undefined) {
      throw new Error("When c is positive, x should never be undefined");
    }

    (x + 1); // works because x has been refined to "number"
  }
}

Не очень по сравнению с invariant, но не такая уж важная проблема.

$ExpectError против @ts-ignore


У Flow есть очень интересная функция, похожая на @ts-ignore, за исключением того, что выдаёт ошибку в том случае, если следующая строка не является ошибкой. Это очень полезно для написания «тестов для типов», которые гарантируют, что проверка типа (будь то TypeScript или Flow) находит определённые ошибки типов.

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

Ошибки общих типов и выведение типов


Часто TypeScript допускает более явный код, чем Flow, как в этом примере:

type Leaf = {
  host: string;
  port: number;
  type: "LEAF";
};

type Aggregator = {
  host: string;
  port: number;
  type: "AGGREGATOR";
}

type MemsqlNode = Leaf | Aggregator;

function f(leaves: Array<Leaf>, aggregators: Array<Aggregator>): Array<MemsqlNode> {
  // The next line errors because you cannot concat aggregators to leaves.
  return leaves.concat(aggregators);
}

Flow выводит тип leaves.concat(aggregators) как Array<Leaf | Aggregator>, который затем может быть приведён к Array<MemsqlNode>. Думаю, это хороший пример, где Flow бывает чуть умнее, а TypeScript нуждается в небольшой помощи: в этом случае можем применить утверждение типа (type assertion), но это опасно и следует делать очень осторожно.

Хотя у меня нет формальных доказательств, но я считаю, что Flow намного превосходит TypeScript в выведении типов. Очень надеюсь, что TypeScript достигнет уровня Flow, поскольку язык очень активно развивается, и многие недавние улучшения сделаны именно в этой области. Во многих местах нашего кода приходилось немного помогать TypeScript через аннотации или утверждения типов, хотя мы избегали последних, насколько возможно). Рассмотрим ещё один пример (у нас оказалось более 200 таких ошибок):

type Player = {
    name: string;
    age: number;
    position: "STRIKER" | "GOALKEEPER",
};

type F = () => Promise<Array<Player>>;

const f1: F = () => {
    return Promise.all([
        {
            name: "David Gomes",
            age: 23,
            position: "GOALKEEPER",
        }, {
            name: "Cristiano Ronaldo",
            age: 33,
            position: "STRIKER",
        }
    ]);
};

TypeScript не позволит вам такое написать, потому что не позволит заявить { name: "David Gomes", age: 23, type: "GOALKEEPER" } в качестве объекта типа Player (точную ошибку см. в песочнице). Это ещё один случай, где я считаю TypeScript недостаточно «умным» (по крайней мере, по сравнению с Flow, который понимает этот код).

Есть несколько вариантов, как это исправить:

  • Заявить "STRIKER" как "STRIKER", чтобы TypeScript понял, что строка является допустимым перечислением типа "STRIKER" | "GOALKEEPER".
  • Заявить все объекты как Player.
  • Или то, что я считаю лучшим решением: просто помочь TypeScript, не используя никаких утверждений типов, написав Promise.all<Player>(...).

Вот другой пример (TypeScript), где Flow опять лучше в выведении типа:

type Connection = { id: number };

declare function getConnection(): Connection;

function resolveConnection() {
  return new Promise(resolve => {
    return resolve(getConnection());
  })
}

resolveConnection().then(conn => {
  // TypeScript errors in the next line because it does not understand
  // that conn is of type Connection. We have to manually annotate
  // resolveConnection as Promise<Connection>.
  (conn.id);
});

Очень маленький, но интересный пример: Flow считает Array<T>.pop() типом T, а TypeScript считает, что это будет T | void. Очко в пользу TypeScript, потому что он заставляет дважды проверить существование элемента (если массив пуст, то Array.pop возвращает undefined). Есть несколько других небольших примеров вроде этого, где TypeScript превосходит Flow.

Определения TypeScript для сторонних зависимостей


Конечно, при написании любого JavaScript-приложения у вас появится хотя бы несколько зависимостей. Их следует типизировать, иначе вы потеряете большую часть возможностей статического анализа типов (как описано в начале статьи).

Библиотеки из npm могут поставляться с определениями типов Flow или TypeScript, с обоими или без ничего. Очень часто (небольшие) библиотеки не поставляются ни с тем, ни с другим, так что приходится писать собственные определения типов или заимствовать их у сообщества. И Flow, и TypeScript поддерживают стандартные репозитории определений для сторонних пакетов JavaScript: это flow-typed и DefinitelyTyped.

Должен сказать, что DefinitelyTyped нам понравился гораздо больше. С flow-typed пришлось использовать инструмент CLI, чтобы ввести в проект определения типов для различных зависимостей. DefinitelyTyped объединяет эту функцию с инструментом CLI npm, отправляя пакеты @types/package-name в репозиторий пакетов npm. Это очень классно и значительно упростило ввод определений типов для наших зависимостей (jest, react, lodash, react-redux, это всего несколько).

Кроме того, я отлично провёл время, пополняя базу DefinitelyTyped (не думайте, что определения типов эквивалентны при переносе кода с Flow на TypeScript). Я уже отправил несколько пулл-реквестов, и нигде не возникло проблем. Просто клонируйте репозиторий, редактируйте определения типов, добавляйте тесты — и отправляйте пулл-реквест. GitHub-бот DefinitelyTyped помечает авторов определений, которые вы отредактировали. Если ни один из них не предоставит отзыв в течение 7 дней, то пулл-реквест поступает на рассмотрение мейнтейнеру. После слияния с основной веткой новая версия пакета зависимостей отправляется в npm. Например, когда я впервые обновил пакет @types/redux-form, в npm автоматически отправилась версия 7.4.14. так что достаточно обновить файл package.json, чтобы получить новые определения типов. Если никак не дождаться принятия пулл-реквеста, то всегда можете изменить определения типов, которые используются в вашем проекте, как рассказывалось в одной из прошлых статей.

В целом, качество определений типов в DefinitelyTyped намного лучше из-за более крупного и процветающего сообщества TypeScript. Фактически, после переноса проекта на TypeScript у нас покрытие типами увеличилось с 88% до 96% в основном из-за лучших определений типов сторонних зависимостей, с меньшим количеством типов any.

Линтинг и тесты


  1. Мы перешли с линтера eslint на tslint (с eslint для TypeScript показалось сложнее начать работу).
  2. Для тестов на TypeScript используется ts-jest. Некоторые из тестов типизированы, а другие нет (если слишком долго типизировать, мы сохраняем их как файлы .js).

Что произошло после исправления всех ошибок типизации?


После 40 человеко-часов работы мы дошли до последней ошибки типизации, отложив её на время с помощью @ts-ignore.

Рассмотрев комментарии код-ревью и исправив пару багов (к сожалению, пришлось немного изменить код среды выполнения, чтобы исправить логику, которую TypeScript не мог понять) ушёл пулл-реквест, и с тех пор мы используем TypeScript. (И да, мы исправили тот последний @ts-ignore в следующем пулл-реквесте).

Помимо интеграции с редактором, работа с TypeScript очень похожа на работу с Flow. Производительность сервера Flow немного выше, но это не является большой проблемой, потому что они одинаково быстро выдают ошибки для текущего файла. Единственная разница в производительности заключается в том, что TypeScript немного позже (на 0,5?1 с) сообщает о новых ошибках после сохранения файла. Время запуска сервера примерно одинаковое (около 2 минут), но это не так важно. До сих пор у нас не было никаких проблем с потреблением памяти. Похоже, что tsc постоянно использует около 600 МБ.

Может показаться, что функция выведения типов даёт большое преимущество Flow, но есть две причины, почему это не имеет большого значения:

  1. Мы преобразовали кодовую базу Flow на TypeScript. Очевидно, что нам попался только такой код, который Flow может выразить, а TypeScript нет. Если бы миграция происходила в обратном направлении, я уверен, что нашлись бы вещи, которые TypeScript лучше выводит/выражает.
  2. Выведение типов важно, помогая писать более лаконичный код. Но всё-таки важнее другие вещи, такие как сильное сообщество и доступность определений типов, потому что слабое выведение типов можно исправить, потратив чуть больше времени на типизацию.

Статистика кода


$ npm run type-coverage # https://github.com/plantain-00/type-coverage
43330 / 45047 96.19%
$ cloc # ignoring tests and dependencies
--------------------------------------------------------------------------------
Language                      files          blank        comment           code
--------------------------------------------------------------------------------
TypeScript                      330           5179           1405          31463

Что дальше?


Мы не закончили с улучшением статического анализа типов. В MemSQL есть другие проекты, которые в итоге перейдут с Flow на TypeScript (и некоторые проекты JavaScript, которые начнут использовать TypeScript), и мы хотим сделать нашу конфигурацию TypeScript более строгой. В настоящее время у нас включена опция strictNullChecks, но по-прежнему отключена noImplicitAny. Мы также удалим из кода парочку опасных утверждений типов.

Рад поделиться с вами всем, что я узнал во время приключений с типизацией JavaScript. Если интересна какая-то конкретная тема — пожалуйста, дайте знать.




К сожалению, не доступен сервер mySQL