Миграция 17 000 файлов JS на TypeScript. Как это было

Kate

Administrator
Команда форума
17000 файлов JS в Etsy охватывают множество итераций сайта. Разработчику бывает трудно понять, какие части кода считаются лучшей практикой, а какие устарели или считаются техническим долгом.

Полтора года назад мы модернизировали систему сборки JavaScript, чтобы включить стрелочные функции и классы. Эта модернизация означала защиту кодовой базы на будущее и более идиоматичный, масштабируемый JavaScript.

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

Стратегия внедрения​

Множество файлов JavaScript превращаются в TypeScript заменой расширения. Но, чтобы TypeScript полностью понимал файлы, многие из них должны быть аннотированы типами. Даже если TypeScript прекрасно понимает файл, благодаря хорошо определённым типам разработчики выигрывают время. Кроме того, проверка типов TypeScript лучше работает с более строгими типами. Чтобы мигрировать на TS, нужно было ответить на эти вопросы:

  • Насколько строгим должен быть TypeScript?
  • Какой код мы хотим перенести?
  • Насколько конкретными должны быть типы?
Миграция на новый язык требует больших усилий, и если мы имеем дело с TypeScript, то можем использовать все преимущества системы типов языка. Мы решили, что приоритет — строгость, поскольку строгий TypeScript предотвращает распространённые ошибки.

Перенос каждого файла отдельно — это трата времени, но предоставить типы новым и часто обновляемым частям сайта было важно. Хотелось, чтобы типы были максимально полезными и простыми в использовании.

Строгий TS​

Наше решение не было идеальным:

  • Большей части кода JS потребовались аннотации типов.
  • Подход требовал переноса файл за файлом, адаптации команды за командой.
  • В попытке преобразовать всё сразу со строгим TS возникло много требующих решения проблем.
Мы сосредоточились на типизации активно изменяемых областей сайта и расширениями JS/TS чётко разграничили файлы с надёжными и ненадёжными типами. Одновременная миграция затрудняет логистическое совершенствование существующих типов, особенно в монорепозитории. Возникли такие вопросы:

  • Если импортировать файл TypeScript с существующей, но отключённой ошибкой типа, нужно ли исправлять ошибку?
  • Означает ли это, что типы файла должны быть другими, чтобы учесть потенциальные проблемы с этой зависимостью?
  • Кому она принадлежит, безопасно ли её редактировать?
Как выяснила команда, каждая устранённая двусмысленность позволяет инженеру внести улучшения самостоятельно. При инкрементной миграции надёжные типы может иметь любой файл .ts или .tsx.

Поддержка TypeScript нашими инструментами​

Мы хотели, чтобы, до того как инженеры начнут писать на новом языке, он поддерживался в наших инструментах, а все основные библиотеки имели удобные, хорошо определённые типы. Заставлять людей писать типы для обычных утилит, пока они учат новый язык и не отстают от плана команды, — хороший способ вызвать неприятие.

Зависимости без типов в файле TypeScript затрудняют работу с кодом и приводят к ошибкам типов, хотя TypeScript пытается определить типы в файле, который не относится к TypeScript, если это не удаётся, язык возвращает тип “any”. Говоря коротко, если инженер пишет на TypeScript, ему нужна уверенность, что язык отлавливает ошибки типов.

Обучение и онбординг​

Мы потратили на обучение TypeScript много времени, и это решение оказалось лучшим за всю миграцию. Немногие из сотен инженеров Etsy, включая меня, имели опыт работы с TypeScript. И, если включить рубильник и сказать: "Всё готово”, это вызовет у людей замешательство, команду завалят вопросами, а скорость инженеров упадёт.

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

Самые интересные детали и проблемы​

Самым простым в миграции оказалось добавление поддержки TS в сборку. Вот интересные моменты:

  • Для сборки JS мы используем Webpack, последний с помощью Babel транспилирует современный JavaScript в старый, более совместимый.
  • Замечательный плагин babel-preset-typescript быстро превращает TypeScript в JavaScript, но проверку типов нужно делать самостоятельно.
  • Для проверки типов как часть набора тестов запускался компилятор TypeScript. Опция noEmit сообщала компилятору не транспилировать файлы.
Всё это заняло менее двух недель. Большая часть времени ушла на проверку того, что TypeScript в производственной среде не ведёт себя странно. Другие инструменты заняли больше времени и оказались интереснее.

Уточнение типов с помощью typescript-eslint​

ESLint в Etsy активно отлавливает всевозможные плохие паттерны, помогает отказаться от старого кода и делает комментарии к PR информативными. Миграция на TypeScript означала появление множества новых практик. Нужно было подумать и написать для них правила линтинга.

Если что-то важно, то мы стараемся написать для этого правило ESLint. Кроме того, линтинг проверяет, насколько точно тип соответствует тому, что он описывает. Поговорим об этом подробнее.

Представьте функцию, принимающую имя HTML-тега и возвращающую HTML-элемент. Её аргумент — любая строка. Но если функция использует строку, чтобы создать элемент, то неплохо было бы гарантировать, что она совпадает с названием существующего в HTML элемента:

// This function type-checks, but I could pass in literally any string in as an argument.
function makeElement(tagName: string): HTMLElement {
return document.createElement(tagName);
}

// This throws a DOMException at runtime
makeElement("literally anything at all");
If we put in a little effort to make our types more specific, it’ll be a lot easier for other developers to use our function properly.

// This function makes sure that I pass in a valid HTML tag name as an argument.
// It makes sure that ‘tagName’ is one of the keys in
// HTMLElementTagNameMap, a built-in type where the keys are tag names
// and the values are the types of elements.
function makeElement(tagName: keyof HTMLElementTagNameMap): HTMLElement {
return document.createElement(tagName);
}

// This is now a type error.
makeElement("literally anything at all");

// But this isn't. Excellent!
makeElement("canvas");
С более конкретными типами разработчикам проще использовать функцию правильно:

// This is a constant that might be ‘null’.
const maybeHello = Math.random() > 0.5 ? "hello" : null;

// The `!` below is a non-null assertion.
// This code type-checks, but fails at runtime.
const yellingHello = maybeHello!.toUpperCase()

// This is a type assertion.
const x = {} as { foo: number };
// This code type-checks, but fails at runtime.
x.foo;
Проект typescript-eslint предоставил специфичные для TypeScript правила. К примеру, ban-types предостерегает от обобщённого типа Element в пользу более определённого HTMLElement.

Кроме того, мы приняли несколько спорное решение не допускать в нашей кодовой базе утверждения non-null и утверждения типов:

  • Первое утверждение сообщает TypeScript, что нечто — это не null, когда TypeScript полагает, что null допустим.
  • Второе позволяет рассматривать что-либо как любой тип на своё усмотрение.
Эти особенности позволяют переопределить то, как TypeScript вообще понимает тип. Во многих случаях они подразумевают более глубокие проблемы типа. Отказавшись от них, мы заставляем типы быть конкретнее. К примеру, "as" допустимо для преобразования Element в HTMLElement, но, вероятно, вы изначально хотели использовать HTMLElement.

В самом TypeScript нет возможности отключить подобное, но линтинг выявляет и предотвращает развёртывание таких конструкций. Это не значит, что шаблоны однозначно плохи. Но линтинг — это разумный выход из положения. Если as очень, очень нужно — просто добавьте исключение:

// NOTE: I promise there is a very good reason for us to use `as` here.
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const x = {} as { foo: number };

Типы и API​

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

Почти все данные на сайте Etsy проходят через API на PHP, а значит, предоставив типы там, мы быстро получили бы покрытие подавляющей части кода. Чтобы упростить выполнение запроса, для каждой конечной точки мы создаём конфигурации на PHP и JavaScript и используем лёгкую обёртку вокруг запроса — EtsyFetch:

// This function is generated automatically.
function getListingsForShop(shopId, optionalParams = {}) {
return {
url: `apiv3/Shop/${shopId}/getLitings`,
optionalParams,
};
}

// This is our fetch() wrapper, albeit very simplified.
function EtsyFetch(config) {
const init = configToFetchInit(config);
return fetch(config.url, init);
}

// Here's what a request might look like (ignoring any API error handling).
const shopId = 8675309;
EtsyFetch(getListingsForShop(shopId))
.then((response) => response.json())
.then((data) => {
alert(data.listings.map(({ id }) => id));
});
Подобный шаблон у нас встречается часто. Если бы для ответов API мы не генерировали типы, разработчики писали бы их вручную и только надеялись, что эти типы синхронизируются с реальным API. Строгие типы нам были нужны, но не хотелось трудностей с их получением.

А чтобы превратить конечные точки в спецификации OpenAPI, мы воспользовались наработками собственного API для разработчиков. Спецификации OpenAPI — это стандартизированные способы описания конечных точек API в формате JSON.

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

Потратив много времени на генератор спецификаций OpenAPI, работающий со всеми внутренними конечными точками, при помощи openapi-typescript мы преобразовали эти спецификации в типы TypeScript.

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

// These types are globally available:
interface EtsyConfig<JSONType> {
url: string;
}

interface TypedResponse<JSONType> extends Response {
json(): Promise<JSONType>;
}

// This is roughly what a generated API config file looks like:
import OASGeneratedTypes from "api/oasGeneratedTypes";
type JSONResponseType = OASGeneratedTypes["getListingsForShop"];

function getListingsForShop(shopId): EtsyConfig<JSONResponseType> {
return {
url: `apiv3/Shop/${shopId}/getListings`,
};
}

// This is (looooosely) what EtsyFetch looks like:
function EtsyFetch<JSONType>(config: EtsyConfig<JSONType>) {
const init = configToFetchInit(config);
const response: Promise<TypedResponse<JSONType>> = fetch(config.url, init);
return response;
}

// And this is what our product code looks like:
EtsyFetch(getListingsForShop(shopId))
.then((response) => response.json())
.then((data) => {
data.listings; // "data" is fully typed using the types from our API
});
Существующие вызовы к EtsyFetch теперь имели строгие типы “из коробки”, не требовалось никаких изменений. А если обновить API и сломать код на клиенте, сработает проверка типов и сломанный код не попадает в продакшн.

Типизация API также открыла возможность использовать его как единый источник истины между бэкендом и браузером. Гарантировать, что эмодзи с флагом у нас есть для всех локалей API, теперь можно было так:

type Locales OASGeneratedTypes["updateCurrentLocale"]["locales"];

const localesToIcons : Record<Locales, string> = {
"en-us": "🇺🇸",
"de": "🇩🇪",
"fr": "🇫🇷",
"lbn": "🇱🇧",
//... If a locale is missing here, it would cause a type error.
}
И самое приятное: ни одна из этих функций не требовала изменений в рабочих процессах инженеров по продуктам. Используя уже знакомый паттерн, люди получали типы в довесок.

Профилирование и новые впечатления от разработки​

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

Но нам посчастливилось встретиться с мейнтейнерами TypeScript, заинтересованными в успехе языка в уникальной кодовой базе Etsy. Проблемы с редактором их удивили. А выяснив, что TypeScript потребовалось почти 10 полных минут, чтобы проверить весь код, они удивились ещё больше. После обсуждений разработчики TS указали на совсем новую функцию отслеживания производительности. Оказалось, что TS пытался проверить тип немигрировавшего файла. Ниже вы видите трассировку, где ширина — это время.

f4de746d3f790185836920f756439b42.png

Типы безупречно работали со всем кодом, но в ещё не мигрированных частях кода возникали проблемы. Они и приводили к бесконечному циклу. Когда в местах с таким циклом кто-то открывал файл. А когда мы запускали полную проверку типов, TypeScript попадал в бесконечный цикл, тратил много времени в попытке разобраться, сдавался и выдавал ошибку типа.

Тот самый тип вызывал проблемы и в других местах: после исправления проверка всего кода ускорилась в три раза, а использование памяти сократилось на 1 Гб. Посмотрите на трассировки и графики:

ab8cd46c89c5ca01a36604f79e098eb2.png
eeab504d8c2ccdd6a2c0dbfdc9cbdaf6.png
9d1c822d451bf732cff890ff30f306bf.png

Как мы изучали TS​

Мы искали команды, собиравшиеся начать новые проекты с относительно гибкими сроками, и спрашивали, интересен ли им TypeScript. Пока они работали, наша единственная задача состояла в том, чтобы просматривать их PR, писать необходимые типы модулей и по мере обучения работать с ними.

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

Первые команды во многом определили правила линтинга и помогли убедиться, что документация понятна и полезна. Также мы получили время, необходимое для завершения технических моментов миграции, таких как добавление типов в API.

Обучение команд​

Почувствовав, что устранено большинство недочётов, мы брали в работу любую проявившую интерес команду. Чтобы подготовить людей к TypeScript, мы нашли курс от ExecuteProgram, который, по нашему мнению, хорошо подходит для этого и попросили их сначала пройти обучение.

Курс должна была пройти вся команда, кроме тех, кто имел аналогичный опыт. А ещё мы связались с Дэном Вандеркамом, автором книги Effective TypeScript, чтобы узнать, будет ли ему интересно прочитать внутренний доклад. Я разработал виртуальные значки, их мы выдавали в середине и в конце курса, чтобы поддержать мотивацию и проследить скорость изучения TypeScript.

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

От сложных инструментов автоматической миграции (как в AirBnB) мы отказались отчасти потому, что они лишают некоторых возможностей обучения. Кроме того, инженер с небольшим знанием контекста перенесёт файл эффективнее, чем скрипт.

Внедрение по командам​

Мы должны были предотвратить использование TypeScript отдельными инженерами до полной готовности команды. TypeScript — действительно классный язык, и люди стремились попробовать его, особенно увидев, что он используется в нашем коде. Чтобы предотвратить преждевременное использование языка, мы написали для коммитов git простой хук, который запрещал изменения TypeScript от пользователей вне доверенного списка. Когда команда была готова, она добавлялась в этот список.

Поддержка команд после онбординга​

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

Мы ограничили наши ревью синтаксисом и, по мере роста, обращались за помощью к инженерам, которые успешно перешли на TypeScript. Эту группу мы назвали TypeScript Advisors, и она стала бесценным источником поддержки новоиспечённых TypeScript-инженеров.

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

 
Сверху