Сбой при миграции часто маскируется под успех: файл уже называется .ts, editor показывает подсказки, но ответ сервера с неверным полем доходит до рендера без остановки. Затем разработчик добавляет any, чтобы снять ошибку compiler, и дефект возвращается в production под видом временного исключения. Цена такого решения — не одна неточная аннотация. Непонятно, в каком модуле данные перестали быть проверяемыми, поэтому следующая ошибка снова расследуется по стеку и логам.
Нужно разделить две вещи. TypeScript проверяет отношения в исходном коде и затем убирает собственные типовые записи из исполняемого JavaScript. Он не ставит проверяющий код перед JSON.parse и не меняет ответ HTTP только потому, что рядом есть interface. В 2019 году это означает простой порядок: признать внешнее значение неизвестным, проверить его обычным JavaScript-кодом и только потом передать в типизированную часть. Такой контур уже даёт практическую границу без обещания магической защиты всего приложения.
Что именно исчезает при компиляции
Type, interface, type assertion и параметрический тип нужны compiler и редактору. В runtime они не становятся объектами, которые можно спросить у браузера или Node.js. Это хорошо для совместимости: TypeScript строится поверх JavaScript, а выходной код продолжает исполняться как JavaScript. Но отсюда следует ограничение: объявление Profile не проверит сетевой payload. Если вход неверный, его нужно отклонить до вызова функции, которая полагается на Profile.
Ниже одна и та же функция показана до и после компиляции. В выходном файле нет type Profile и нет аннотации параметра. Строка с return осталась, потому что она выполняется. Это удобная проверка здравого смысла для ревью: если требование должно жить в runtime, ищем условие, parser или test, а не только type alias в соседнем файле.
// profile.ts
type Profile = { id: string, email: string };
export function profileLabel(profile: Profile): string {
return profile.id + " <" + profile.email + ">";
}
// profile.js after compilation
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
function profileLabel(profile) {
return profile.id + " <" + profile.email + ">";
}
exports.profileLabel = profileLabel;
Поток значений: внешний вход, проверка, типизированное ядро
У любого внешнего значения есть источник: HTTP-ответ, local storage, элемент формы, глобальная переменная или callback старой библиотеки. Внутри TypeScript-модуля хочется пользоваться понятной моделью. Между ними должна появиться функция, которая умеет ответить «нет». Она проверяет только свойства, от которых зависит текущая операция, и возвращает модель либо null или ошибку. Такой код выполняется в runtime, поэтому его можно покрыть fixture с неполным объектом.
unknown удобен именно на этом краю. Нельзя читать свойство у unknown, пока код не доказал, что значение имеет нужную форму. Это создаёт маленькое трение в правильном месте: автор вынужден назвать условия, при которых payload считается Member. any ведёт себя иначе: он разрешает обращение к полям без проверки и переносит неопределённость дальше. В миграции any допустим как явно записанный временный долг с владельцем и сроком, но не как способ сделать список ошибок пустым.
type Member = {
id: string;
email: string;
status: "active" | "blocked";
};
function isMember(value: unknown): value is Member {
if (!value || typeof value !== "object") return false;
const record = value as { [key: string]: unknown };
return typeof record.id === "string"
&& typeof record.email === "string"
&& (record.status === "active" || record.status === "blocked");
}
export function normalizeMember(value: unknown): Member | null {
return isMember(value) ? value : null;
}
Функция isMember не является полной схемой всего сервиса. Она не знает, какие дополнительные поля сервер может вернуть, и не пытается валидировать их заранее. Её контракт уже полезен: экрану разрешено видеть только id, email и два состояния status. Если придёт status со значением archived, функция вернёт false, а задача должна решить, как пользователь увидит это несовпадение. Скрыть его в any значит отложить это решение в наиболее дорогую точку — пользовательский сбой.
Почему any ломает трассировку ответственности
В языке any не просто «широкий тип». Он ослабляет проверку операций на значении. Если payload объявлен any, выражение payload.user.email получает разрешение без доказательства существования user и email. Следующая функция может принять результат как string, хотя источник ничего такого не гарантировал. В цепочке исчезает место, где можно остановить неверные данные, а code review начинает спорить о вкусе типов вместо конкретного контракта.
| Участок | Форма значения | Что проверяет TypeScript | Что должен делать runtime-код |
|---|---|---|---|
| Ответ транспорта | unknown или неясный JavaScript-объект | не разрешает пользоваться полями без сужения, если вход честно объявлен | проверить наличие и тип нужных полей |
| Нормализатор | unknown на входе, Member на выходе | связывает результат с обещанным контрактом функции | вернуть null или ошибку при несовпадении |
| Компонент или formatter | Member | ловит опечатку поля и несовместимое использование статуса | показать модель, не повторяя сетевую проверку |
| Переменная any | любая форма без доказательства | многие операции допускаются без полезной диагностики | не даёт автоматической runtime-защиты и скрывает место долга |
Таблица не говорит, что TypeScript всегда откажется от неверного JavaScript. Результат зависит от того, как объявлен вход и какие настройки применены к файлу. Она задаёт проверяемую модель ответственности: transport не обещает форму, normalizer проверяет форму, а остальная часть системы работает с уже названным контрактом. Это проще поддерживать, чем один общий type для всего JSON-ответа, который никто не умеет сопоставить с фактическими данными.
allowJs и checkJs — это разные рычаги
allowJs отвечает за состав входных файлов compiler: JavaScript остаётся рядом с TypeScript. Это разрешает менять модуль малыми шагами и не обрывать импорт соседей из-за одного расширения. checkJs отвечает за диагностику внутри JavaScript. В режиме проекта он включает сообщения для .js-файлов; локальный комментарий @ts-check подходит, когда сначала нужна проверка одного опасного перехода. Смешивать эти роли опасно: включённый allowJs ещё не означает, что старый JavaScript получил проверку.
На практике я строю лестницу. Сначала compiler видит дерево и ничего не генерирует. Затем один boundary-файл получает @ts-check и JSDoc. После этого новый маленький модуль можно написать на .ts, оставить transport на .js и проверить их интерфейс. Лишь когда такие швы повторяются, обсуждаю расширение checkJs или общий уровень строгости. У этой последовательности есть важное свойство: каждая ошибка привязана к конкретной операции, а не к тысячи строк, до которых очередь ещё не дошла.
TS 3.5: версия тоже участвует в контракте
Статья привязана к осени 2019 года, поэтому пример не опирается на поздний синтаксис и на современные решения для схем. TypeScript 3.5 уже поддерживает unknown, type predicates, JSDoc-проверку JavaScript и настройки allowJs/checkJs. В заметках к 3.5 отдельно описаны корректировки поведения непараметризованных generic-параметров и улучшения проверки; это причина не переносить случайные рецепты из другой версии без воспроизведения на версии проекта.
Из версии следует и дисциплина ревью. Если project compiler обновляется вместе с миграцией, обновление — отдельная ось риска: новые diagnostics могут быть полезны, но их нельзя выдать за результат одного переименования файла. В отчёте нужно назвать версию TypeScript, команду проверки и fixture. Тогда будущий апгрейд сможет сравнить изменения compiler с исходной точкой, а не искать, откуда внезапно пришло сообщение.
Короткий диагностический маршрут
- Найти место, где неясное значение впервые входит в код: ответ, callback, конфигурация или форма. Не начинать с модели экрана, если источник ещё не назван.
- Проверить, есть ли runtime-условие на нужные поля. Если есть только interface, добавить fixture с неполным объектом и увидеть, что происходит до рендера.
- Заменить временный any на unknown в одном входе. Описать минимальное сужение через typeof, проверку объекта и допустимые варианты значений.
- Поставить результат проверки в явный типизированный контракт функции. Следующий модуль должен принимать Member, а не объект с произвольными полями.
- Включить @ts-check в соседнем JavaScript-файле либо добавить его в tsconfig через checkJs только после того, как причина диагностик понятна.
- Запустить type-check и обычный build. Записать, что именно подтверждено командами, а что ещё требует интеграционного или runtime-теста.
Что этот механизм не обещает
Статический анализ не заменяет контракт с сервером, контрактные тесты и наблюдение за ошибками после выпуска. Runtime-проверка, в свою очередь, не делает модель автоматически удобной во всех модулях. Но вместе они делят работу честно: код на границе отвечает за фактический вход, TypeScript помогает не потерять проверенную форму дальше по графу.
Если в ревью для нового файла появляется много any, я не закрываю задачу до переименования. Я спрашиваю, какие данные не смогли описать и почему. Иногда ответом будет маленький отдельный контракт, иногда — необходимость оставить модуль JavaScript до исследования API. Оба исхода лучше, чем визуально завершённая миграция, в которой compiler больше ничего не может сообщить.
Проверяемые источники
- TypeScript Handbook: Migrating from JavaScript — описывает постепенный переход: JavaScript остаётся входом компилятора при allowJs, а файлы можно переводить по одному
- TSConfig Reference: allowJs — разрешает включать JavaScript-файлы вместе с TypeScript и тем самым не требует одномоментного переименования всего дерева
- TSConfig Reference: checkJs — включает диагностические сообщения для JavaScript-файлов; эквивалентный локальный маркер — комментарий @ts-check
- TypeScript 3.5 release notes — майский релиз 2019 года: в нём зафиксированы улучшения проверки, incremental-сборки и уточнения поведения generic-параметров