DarkRiDDeR14 мин

Под капотом: почему типы не проверяют runtime и как any размывает границу

TypeScriptFrontendРазбор механизма

Сбой при миграции часто маскируется под успех: файл уже называется .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;
Схема контракта TypeScript: сетевой payload остаётся unknown, функция нормализации проверяет поля и выпускает Member в типизированное ядро; отдельная красная дорожка показывает, как any пропускает данные мимо проверки
Тип не заменяет runtime-проверку. Он становится полезным после того, как неопределённый вход остановлен или нормализован на границе.

Поток значений: внешний вход, проверка, типизированное ядро

У любого внешнего значения есть источник: 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 начинает спорить о вкусе типов вместо конкретного контракта.

Что compiler знает на каждом участке
УчастокФорма значенияЧто проверяет TypeScriptЧто должен делать runtime-код
Ответ транспортаunknown или неясный JavaScript-объектне разрешает пользоваться полями без сужения, если вход честно объявленпроверить наличие и тип нужных полей
Нормализаторunknown на входе, Member на выходесвязывает результат с обещанным контрактом функциивернуть null или ошибку при несовпадении
Компонент или formatterMemberловит опечатку поля и несовместимое использование статусапоказать модель, не повторяя сетевую проверку
Переменная 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 с исходной точкой, а не искать, откуда внезапно пришло сообщение.

Короткий диагностический маршрут

  1. Найти место, где неясное значение впервые входит в код: ответ, callback, конфигурация или форма. Не начинать с модели экрана, если источник ещё не назван.
  2. Проверить, есть ли runtime-условие на нужные поля. Если есть только interface, добавить fixture с неполным объектом и увидеть, что происходит до рендера.
  3. Заменить временный any на unknown в одном входе. Описать минимальное сужение через typeof, проверку объекта и допустимые варианты значений.
  4. Поставить результат проверки в явный типизированный контракт функции. Следующий модуль должен принимать Member, а не объект с произвольными полями.
  5. Включить @ts-check в соседнем JavaScript-файле либо добавить его в tsconfig через checkJs только после того, как причина диагностик понятна.
  6. Запустить 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-параметров