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

30e37a5a65443fd08c36e1e41cd316c4.jpg

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

Если вы хотите перейти на TS, читайте эту статью, чтобы избежать ошибок Etsy и взять на вооружение лучшие решения компании. Подробности миграции рассказываем, пока у нас начинается курс по Fullstack-разработке на Python.

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 {
   url: string;
}
 
interface TypedResponse extends Response {
   json(): Promise;
}
 
// This is roughly what a generated API config file looks like:
import OASGeneratedTypes from "api/oasGeneratedTypes";
type JSONResponseType = OASGeneratedTypes["getListingsForShop"];
 
function getListingsForShop(shopId): EtsyConfig {
   return {
       url: `apiv3/Shop/${shopId}/getListings`,
   };
}
 
// This is (looooosely) what EtsyFetch looks like:
function EtsyFetch(config: EtsyConfig) {
   const init = configToFetchInit(config);
   const response: Promise> = 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 = {
   "en-us": "
    
            

© Habrahabr.ru