Перевод JS-проекта на TypeScript

настройка≈ 11 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Проект на JavaScript не переводят на TypeScript одним коммитом. Компилятор умеет держать в одной сборке оба расширения, проверять .js-файлы по требованию и читать типы, написанные в комментариях JSDoc. Поэтому миграция — это несколько флагов в tsconfig.json и очередь файлов, а не остановка работы на месяц.

Разделы темы:·

По умолчанию компилятор видит только .ts и .tsx. В проекте, где четыреста файлов на JavaScript и один новый на TypeScript, это заметно с первой же строки: импорт старого модуля даёт TS7016: Не удалось найти файл объявления модуля "./utils.js" — и уточнение, что модуль получил неявный тип any.

allowJs: true снимает ограничение: .js-файлы попадают в программу как обычные исходники. Компилятор их разбирает, выводит из них типы, отдаёт подсказки редактору и — если сборкой занимается он же — компилирует наравне с .ts. Чего он при этом не делает, так это не жалуется: ошибки внутри старых файлов остаются невидимыми, пока не включён checkJs.

Стоит осознать, что вы получили в этот момент бесплатно. Вывод типов не требует аннотаций — об этом была статья про наведение в начале части. Компилятор знает форму каждого объектного литерала и тип каждой переменной в вашем старом коде, просто пока молчит о находках.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Свойство "rub" не существует в типе "{ usd: number; eur: number; }" — компилятор перечислил в сообщении ровно то, что вывел из литерала двумя строками выше, без единой подсказки от автора. И обратите внимание на вывод: не падение, а NaN. Такие ошибки в JavaScript живут дольше всех — undefined * 100 не бросает исключение, а тихо расползается по отчёту.

allowJs — про состав сборки, checkJs — про придирки. Первый флаг ничего не ломает: он лишь пускает старые файлы внутрь.

Если tsc у вас не только проверяет, но и собирает, allowJs приносит с собой ловушку: скомпилированный .js норовит лечь на место исходного .js. Компилятор это ловит — TS5055: Не удается записать файл "src/utils.js", так как это привело бы к перезаписи входного файла.

Выходов два. Если сборкой занимается бандлер, поставьте noEmit: true — компилятор останется чистым проверяльщиком, и вопрос снимется сам. Если собирает tsc, задайте outDir (а заодно и rootDir), чтобы результат уезжал в отдельную папку.

checkJs: true включает для .js-файлов те же проверки, что работают в .ts. На проекте, который писали годами, это обычно означает несколько тысяч ошибок разом — цифру, после которой флаг выключают обратно и к миграции больше не возвращаются.

Поэтому у проверки есть вторая передача, файловая: checkJs остаётся выключенным, а в первую строку конкретного файла ставится комментарий // @ts-check. Проверяется только он. Очередь двигается по одному файлу, и в любой момент проект зелёный.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Оператор "+" невозможно применить к типам "number" и "string | number". Цена второго товара — строка, и reduce со второго шага перешёл на склейку: в консоли 49001200 вместо шести тысяч ста. Это типичная находка первого дня — ошибка не в новом коде, а в данных, которые лежали в проекте всё это время и никого не смущали.

Обратная директива — // @ts-nocheck в первой строке файла: он целиком выпадает из проверки, даже когда checkJs включён глобально. Ею закрывают то, что чинить некогда: сгенерированный код, древние скрипты сборки, файл, от которого зависит выкат.

Только не примите checkJs за «уже TypeScript». Это ступень помягче: к JavaScript-файлам компилятор относится снисходительнее и прощает привычные для языка вольности. Тот же самый код после переименования в .ts начинает ругаться — об этом дальше.

Хорошая цель на первую неделю — не «ноль ошибок», а «мы знаем их число». Включили checkJs, посчитали, выключили обратно.

Аннотацию в .js-файл не поставишь — это уже не JavaScript, и такой файл не запустит ни браузер, ни Node. Зато компилятор читает типы из комментариев JSDoc и понимает их всерьёз: те же проверки, те же коды ошибок, те же типы для всех, кто этот модуль импортирует.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Для среды выполнения это комментарии, и пример выше просто выполняется — песочница не пропускает .js через компилятор. А в проекте с // @ts-check в шапке вызов withVat("4900") дал бы TS2345: Аргумент типа "string" нельзя назначить параметру типа "number" — ровно то же самое, что сказала бы аннотация price: number в .ts-файле.

Что нужноВ .js через JSDocВ .ts
Тип параметра@param {number} priceprice: number
Необязательный параметр@param {number} [rate]rate?: number
Тип возврата@returns {number}): number {
Тип переменной/** @type {string[]} */ перед объявлениемconst names: string[]
Свой тип объекта@typedef {object} User и строки @propertytype User = { … }
Тип из соседнего модуля@type {import("./api.js").User}import type { User }
Утверждение типа/** @type {HTMLInputElement} */ (el)el as HTMLInputElement

Зачем размечать то, что всё равно станет .ts? Ради соседей. Типы из JSDoc видны снаружи: новый .ts-файл, который импортирует размеченный модуль, получит настоящие типы вместо any. Поэтому общие модули — утилиты, клиент к API, форматтеры — размечают первыми, ещё до всякого переименования. А код, который переводить не собираются вовсе, так и живёт с типами в комментариях: скрипты сборки и старые страницы прекрасно себя чувствуют.

Одного JSDoc не переживает — переименования.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

В .ts-файле компилятор JSDoc-типы не читает: там для этого есть синтаксис. Параметр остался голым, отсюда TS7006, а на исполнении "4900" + 980 честно склеилось в строку. Перенос делается почти механически по таблице выше, и редактор помогает: на такой функции у него есть быстрое действие «Annotate with type from JSDoc», которое переписывает сигнатуру за вас.

JSDoc-типы — не «типы для бедных», а тот же самый компилятор. Меняется только место, где написана аннотация.

Очередь на переименование выстраивают снизу вверх: сначала листья — модули, которые сами ничего не импортируют, затем их потребители, точка входа последней. Тогда каждый новый .ts-файл опирается на уже типизированный код, а не на any, и типы не приходится переписывать по второму разу.

Бесплатным переименование не бывает даже там, где код заведомо рабочий. Вот файл, который годами считал итоги и ни разу не ошибся.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Пять сообщений, три из них — TS7053: Элемент неявно имеет тип "any", так как выражение типа "any" не может использоваться для индексации типа "{}". Ни одно не про ошибку в логике. У параметров нет типов — это знакомая TS7006; а тип {}, выведенный из пустого литерала, не имеет ни одного свойства, поэтому запись по ключу компилятору непонятна. Вывод при этом правильный: типы живут только на этапе сборки, код как считал, так и считает.

Занятная деталь: переименование само по себе эти сообщения не породило — те же пять компилятор выдал бы и на .js, стоило включить там checkJs с noImplicitAny. Изменилось другое: у старого файла проверку можно было просто не включать, а .ts проверяется без всяких флагов. Настоящая поблажка для JavaScript-файлов работает на другом — на дописывании свойства через точку: const config = {}; config.retries = 3; в .js проходит молча, а в .ts даёт TS2339; запись по вычисляемому ключу, как в примере выше, ею не прикрыта. Добрая половина «внезапных» ошибок первого дня — вот такие: не новые баги, а старые вопросы, которые наконец задали вслух.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Починка уместилась в три аннотации, поведение не изменилось ни на йоту. Таким коммит и должен быть: переименовали, расставили типы, ничего не переписали. Смешивать переименование с рефакторингом — верный способ получить ревью на восемьсот строк, где не видно ни того ни другого.

Старый код на функциях-конструкторах спотыкается о this: в обычной функции у него нет типа, и компилятор говорит об этом прямо — TS2683.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Лечится переписыванием в class, о котором была статья про класс как тип и как значение. Второе, обо что спотыкается миграция, — module.exports и require: в .ts-файле module просто неизвестное имя, TS2591, пока не подключены типы Node. Про import и export подробно говорила соседняя статья этой главы; при переименовании модуль обычно заодно переводят на ESM.

Правило миграции, которое важнее любого флага: она не должна никого блокировать. Значит, нужен способ сказать «здесь я знаю, что плохо, и вернусь позже» — так, чтобы сборка была зелёной, а долг остался на виду.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

// @ts-expect-error гасит ровно одну ошибку — ту, что на следующей строке. Текст после директивы компилятор не читает, но пишут его для людей: подпорка без объяснения через месяц неотличима от небрежности. И посмотрите на вывод: диагностику мы заглушили, а в поле по-прежнему строка. Директива чинит сборку, а не код.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Вот главное отличие от старой директивы // @ts-ignore: @ts-expect-error требует, чтобы ошибка под ним была. Как только строку починили, подпорка сама поднимает руку — Unused '@ts-expect-error' directive. (по-английски даже при --locale ru: перевод у этой строки есть, но подставить его компилятор здесь не берётся). Долг не забывается: о нём напоминают ровно в тот день, когда его можно списать.

ДирективаГде ставитсяЧто делает
// @ts-checkпервая строка .js-файлавключает проверку одного файла, когда checkJs выключен
// @ts-nocheckпервая строка файласнимает проверку с файла целиком
// @ts-expect-errorстрока перед ошибкойгасит одну ошибку и требует, чтобы она существовала
// @ts-ignoreстрока перед ошибкойгасит одну ошибку молча, даже если её уже нет

А самая опасная подпорка выглядит безобиднее всех.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

as any гасит не одну ошибку, а все, что могли бы вырасти из этой переменной: компилятор молчит, TypeError приезжает в браузер. У этой двери есть честный аналог — unknown с проверкой перед использованием, о котором была статья про данные извне. В миграции разница простая: @ts-expect-error и @ts-nocheck находятся поиском по проекту и когда-нибудь исчезают, а as any расползается и остаётся навсегда.

Подпорка хороша, пока она заметна. Если её не видно в поиске и она не жалуется сама — это уже не подпорка, а новый легаси.

Флаги удобно включать в том порядке, в каком растёт доверие к проекту: сначала компилятор просто присутствует, потом смотрит, потом требует.

ШагЧто включаемКогда идти дальше
1. Впустить компиляторallowJs, noEmit или outDir, отдельный шаг tsc в CIсборка зелёная, .ts-файлов ещё нет
2. РазведкаcheckJs локально: посчитать ошибки и выключить обратноизвестно, сколько их и в каких файлах
3. Разметить фундамент// @ts-check и типы в JSDoc в общих модуляхутилиты и клиент к API отдают настоящие типы
4. Переименовать.js → .ts, листья первымиточка входа стала .ts
5. Дожать строгостьnoImplicitAny, затем весь strictTS7006 больше не появляется
6. Убрать подпоркиснять @ts-nocheck, вычистить as anyany остался только на границе программы

Два пункта здесь важнее прочих. Первый — tsc в CI с самого начала, ещё когда он не находит ни одной ошибки: иначе к третьей неделе выяснится, что половина новых файлов не проверялась вообще. Второй — strict в конце, а не в начале. Свежий tsconfig.json включает строгость сразу, и для нового проекта это правильно; для старого она превращает первый день в стену из тысяч TS7006. Что именно за ней стоит, разбирала вторая статья этой главы.

Прогресс удобно мерить не ошибками, а файлами: сколько модулей уже .ts, сколько ещё под // @ts-nocheck. Число ошибок скачет от каждого флага и ничего не говорит команде, а число переведённых файлов растёт монотонно, и по нему видно, что миграция жива.

Худшая миграция — трёхмесячная ветка «переводим на TypeScript». Хорошая незаметна: каждый день пара файлов, и main всегда собирается.

  • allowJs пускает .js-файлы в программу: компилятор их читает и выводит из них типы, но об ошибках внутри молчит.
  • checkJs включает проверку старых файлов целиком, // @ts-check — по одному файлу, // @ts-nocheck снимает её обратно.
  • Типы для .js пишутся в JSDoc; они видны импортёрам, но перестают работать в момент переименования файла в .ts.
  • Файлы переводят снизу вверх, отдельными коммитами: чаще всего всплывают TS7006 у параметров и TS7053 у объекта, объявленного как {}.
  • @ts-expect-error гасит одну ошибку и сам сообщает, когда стал не нужен; as any не гасит ничего — он выключает проверки и доводит дело до TypeError.
  • strict включают последним шагом, а прогресс миграции меряют числом переведённых файлов, а не числом ошибок.

1/4В tsconfig.json включили allowJs, checkJs не трогали. Что теперь происходит со старыми .js-файлами?

2/4Файл со всеми типами в JSDoc переименовали из utils.js в utils.ts и больше ничего не меняли. Что скажет компилятор?

3/4Строку под // @ts-expect-error починили, и ошибки там больше нет. Что произойдёт?

4/4Код const cache = {}; cache.user = "Рико" лежал в .js-файле с checkJs и не вызывал ни одной жалобы. После переименования в .ts появилась TS2339. Почему?

Разметить модуль, не переименовывая

Модуль ещё долго останется .js, но его импортируют из нового кода на TypeScript, и там он сейчас выглядит как any. Опишите типы в JSDoc так, чтобы вызов discount("4900", 15) в файле с // @ts-check стал ошибкой.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Параметрам нужен блок @param, результату — @returns. Комментарий ставится прямо перед функцией, и с этого момента компилятор знает о ней столько же, сколько знал бы об аннотированной функции в .ts.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Последняя строка теперь даёт Аргумент типа "string" нельзя назначить параметру типа "number". Файл при этом остался обычным JavaScript: его по-прежнему запускает браузер, а строку // @ts-check можно снять в тот день, когда модуль всё-таки станет .ts.

Первый день после переименования

Файл переименовали, и компилятор выдал TS7006 с TS7053. Расставьте типы так, чтобы диагностик не осталось, а поведение не изменилось: в консоли должна остаться единица.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Претензии две, и обе про недостающие типы: у параметра word его нет, а {} — это тип без единого свойства, писать в него по ключу нельзя. Словарь с заранее неизвестными ключами описывает Record, о котором была статья про индексные сигнатуры.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Три аннотации, ноль изменений в логике — таким и должен быть коммит переименования. Если ключи на самом деле известны заранее, вместо Record<string, boolean> лучше перечислить их объединением: тогда опечатка в имени тоже станет ошибкой.

Убрать подпорку

Утверждение as Settings поставили во время миграции, чтобы файл собрался. Компилятор молчит, а пример падает. Перепишите начало так, чтобы недостающее поле обнаружилось до обращения к нему, — и пусть в консоли останется осмысленное сообщение.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Разобранный JSON — это данные извне, и честный тип для них unknown. Проверку формы удобно оформить предикатом: после if компилятор сузит unknown до Settings сам.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Компилятор снова молчит — но теперь по делу: ни одного обращения к полю, существование которого не проверено. Внутри предиката as остался, и это нормально: он заперт в одной функции, а не расползается по всему коду.