← Глава 30. Вывод против явности Содержание Глава 32. Эстетика TypeScript →

Глава 31. Строгость как спектр

Глава 31. Строгость как спектр

Один и тот же зелёный

Команда включает один флаг — noUncheckedIndexedAccess, тот самый, что заставляет доступ по индексу возвращать T | undefined вместо T. На бумаге это честнее: массив может не иметь элемента под этим индексом, и компилятор перестаёт притворяться, будто имеет.

const items: { sku: string; qty: number }[] = [];
const first = items[0];     // тип: { sku: string; qty: number } | undefined
console.log(first.qty);     // 'first' is possibly 'undefined'.

Через день сборка тонет в сотнях таких ошибок. Каждый arr[i], каждый record[key], каждый разбор parts[1] после split — всё теперь | undefined, всё требует проверки или !. Половину этих обращений команда знает безопасными: цикл по for (let i = 0; i < arr.length; i++) не выйдет за границу, но компилятор этого не доказывает. Через неделю войны флаг выключают обратно.

Вопрос: это провал? Дисциплина просела, команда не дотянула до правильного уровня? Или это было верное инженерное суждение — флаг покупал мало, а стоил много, и его выключили осознанно?

Рядом, в той же кодовой базе, два других сюжета. Есть быстрый скрипт миграции данных, который запустят один раз и выбросят. Он собран под тем же максимально строгим tsconfig, и автор, чтобы продраться сквозь проверки за отведённый час, насовал в него as any в дюжине мест. Формально строгость соблюдена — tsc молчит. Фактически файл насквозь дырявый, и эти дыры хуже, чем если бы он честно стоял на послабленной конфигурации без единого any. И есть легаси-модуль, въехавший в проект вместе с поглощённой командой; он тащит свой tsconfig, где половина строгих флагов выключена, и слово «компилируется» применительно к нему не значит почти ничего.

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

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

Строгость — это спектр, а не выключатель

Соберём шкалу с одного конца. Самое нестрогое, что можно сделать с .ts-файлом, — вообще не проверять его, только стереть типы и выполнить остаток. Современный Node это умеет, и tsc это умеет тоже:

tsc --noEmit false --noCheck   # стереть и породить .js, ничего не проверяя

На этом конце «компилируется» значит ровно «синтаксис разобрался». Никакого утверждения о корректности форм здесь нет.

Шаг правее — проверка с предельно снисходительной настройкой. strict выключен, всё неаннотированное молча становится any:

// strict: false, noImplicitAny: false
function totalOf(order) {        // order неявно any — и это не ошибка
  return order.total * 1.2;      // any.total — тоже any, никто не возражает
}

totalOf("не заказ");            // проходит проверку. рантайм: NaN

Здесь tsc уже работает, но проверяет так мало, что зелёная сборка почти ничего не обещает. order — any, и всё, что из него вытекает, заражено any; компилятор согласен на любую глупость.

Ещё шаг — strict: true. Тот же файл перестаёт компилироваться: order требует типа, неявный any запрещён, null и undefined больше не растворены во всех типах. Это та база, которую глава 5 включила механически, перечислив, что покупает каждый флаг под зонтиком strict — strictNullChecks, noImplicitAny, strictFunctionTypes и прочие.

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

{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,   // arr[i] становится T | undefined
    "exactOptionalPropertyTypes": true, // { x?: number } ≠ { x: undefined }
    "noImplicitReturns": true,          // все ветки функции обязаны вернуть
    "noFallthroughCasesInSwitch": true  // забытый break — ошибка
  }
}

Ключевое наблюдение: между «просто транспилируй» и «докажи всё, что можно доказать» — не два положения тумблера, а непрерывная шкала. И регулятор на ней не один. Это семейство флагов strict, плюс дополнения сверх него, плюс — что важнее всего — переопределения уже_ внутри_ одного проекта: по каталогам, по файлам, по отдельным строкам. «Компилируется» — не абсолют. Это значение, привязанное к тому, на какой точке шкалы стоит данный кусок кода. Зелёный в файле под noUncheckedIndexedAccess и зелёный в файле под strict: false — два разных утверждения, и путать их так же опасно, как путать «прошло проверку» и «верно в рантайме».

Разная строгость для разного кода

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

Возьмём ядро домена — логику, которая считает деньги и двигает заказ по состояниям. Здесь ошибка дорога: неверный переход статуса или потерянная копейка — это инцидент, а не опечатка в логе. Корректность окупается с запасом, поэтому здесь хочется максимума: strict, noUncheckedIndexedAccess, размеченные объединения с проверкой исчерпанности, никаких any.

type Order = {
  id: string;
  total: number;
  status: "created" | "paid" | "shipped" | "delivered" | "cancelled";
  items: { sku: string; qty: number }[];
};

function nextStatus(o: Order): Order["status"] {
  switch (o.status) {
    case "created":   return "paid";
    case "paid":      return "shipped";
    case "shipped":   return "delivered";
    case "delivered": return "delivered";
    case "cancelled": return "cancelled";
    // забудь ветку — и функция не вернёт во всех путях:
    // Function lacks ending return statement and return type does not include 'undefined'.
  }
}

Та же максимальная строгость — на границе. Данные из сети, ответ платёжного шлюза, тело запроса приходят в рантайм без всяких типов; именно здесь дыра в состоятельности впускает ложь в систему, и именно здесь её надо ловить жёстче всего (главы 24–26).

Теперь другой полюс. Клеевой код, прототип на выброс, скрипт миграции, тестовый хелпер, который собирает фикстуру, — у всего этого цена ошибки низка, а цена церемонии высока. Прототип проверяет идею; если борьба с проверяющим съедает половину времени, он не выполняет свою работу. Здесь послабление законно: война за полную типизацию одноразового кода стоит дороже, чем то, что она покупает.

// scripts/probe.ts — разовая проверка гипотезы, проживёт час
const rows: any = await db.raw("select * from legacy_orders");
for (const r of rows) {
  console.log(r.id, r.amount_cents);   // форму знаем на глаз, доказывать незачем
}

И третий случай — легаси и миграция. Большой нетипизированный или слабо типизированный код нельзя втащить в strict одним коммитом: включённый флаг вскроет тысячи ошибок разом, и ревью такого изменения невозможно. Сюда годится только постепенный храповик — файл за файлом, по мере того как до них доходят руки (постепенная типизация, глава 11). Граница между «уже строго» и «ещё нет» проходит не по проекту, а внутри него, и со временем сдвигается в одну сторону.

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

Чем калибруют: четыре уровня регулятора

«Разная строгость для разного кода» — это намерение. Чтобы оно стало рабочим, нужны инструменты, которыми регулятор крутят на разных масштабах. Их четыре, от самого крупного к самому мелкому.

Глобальная база. Корневой tsconfig.json задаёт умолчание для всего проекта. Сюда ставится strict: true плюс те дополнения, что окупаются на этой базе. Это пол, ниже которого код в общем случае не опускается.

// tsconfig.json — база проекта
{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true
  }
}

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

// src/legacy/tsconfig.json — въехало с поглощённой командой
{
  "extends": "../../tsconfig.json",
  "compilerOptions": {
    "noUncheckedIndexedAccess": false,  // временно, снимаем по мере миграции
    "strict": false                     // храповик стоит здесь и движется вверх
  }
}

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

Пофайлово. Если в проект пускают .js (allowJs), отдельный файл переключают комментарием в его шапке: // @ts-check включает проверку нетипизированного JS, // @ts-nocheck — выключает проверку файла целиком. Это грубый, бинарный регулятор для случаев, когда гранулярность каталога слишком крупна.

// @ts-nocheck — сгенерированный клиент API, проверять нечего и незачем
export const client = makeClient(/* ... */);

Построчно. Самый мелкий масштаб — подавление одной ошибки на одной строке. Здесь есть выбор из двух инструментов, и он не косметический:

const raw: unknown = JSON.parse(payload);

// @ts-expect-error платёжный шлюз отдаёт snake_case, тип опишем в схеме позже
const id: string = raw.order_id;

@ts-expect-error подавляет ошибку и требует, чтобы ошибка на этой строке была. Как только строку починят и подавлять станет нечего, сам @ts-expect-error превратится в ошибку: Unused '@ts-expect-error' directive. Он самоочищается. Его сосед @ts-ignore глушит строку тихо и навсегда, не возражая, даже когда ошибки давно нет, — о нём в разделе про острые края.

Четыре уровня не конкурируют, а вкладываются друг в друга: база задаёт умолчание, каталог переопределяет поддерево, файл — отдельный модуль, строка — точечное исключение. Чем мельче масштаб послабления, тем оно виднее и тем легче за ним уследить. И последний регулятор, не привязанный к масштабу, — локальный выбор между any и unknown на конкретном значении (глава 11): unknown оставляет дверь запертой и заставляет сузить тип перед использованием, any распахивает её настежь. На границе строгой зоны это разница между «честно не знаю, докажи» и «проверяющий, отойди».

Храповик: только туже и осознанно

Спектр и адресность порождают опасность: если строгость можно крутить в обе стороны и в любом месте, она начнёт сползать вниз под давлением дедлайнов. Сегодня выключили флаг «временно», завтра добавили @ts-ignore, чтобы пройти CI, послезавтра никто не помнит, что и почему ослаблено. Свобода калибровки без дисциплины превращается в медленную эрозию.

Лекарство — правило храповика: строгость движется только в одну сторону. Со временем туже, никогда не слабее молча. Включай флаги по мере того, как код дотягивается до них; каждое послабление — осознанное, локализованное, прокомментированное, прошедшее ревью.

Конкретно это значит вот что. Послабление в отдельном tsconfig каталога — допустимо, потому что оно видимо и помечено как долг. Тихий as any посреди строгого модуля — нет, потому что он невидим. И там, где приходится подавить одну строку, выбор инструмента и есть проверка на осознанность:

// плохо: глушит навсегда и молча, переживёт причину своего появления
// @ts-ignore
const id = raw.order_id;

// хорошо: помечено причиной и само исчезнет, когда станет не нужно
// @ts-expect-error snake_case с шлюза; перейдём на схему в OrderSchema
const id = raw.order_id;

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

Теперь — главное, ради чего вся глава. Храповик движется к строгости, но не к максимальной строгости как самоцели. Цель — нужная строгость за свою цену, а не максимум везде. У строгости есть плата, и она реальна: ложные срабатывания на коде, который ты знаешь корректным; россыпь | undefined от noUncheckedIndexedAccess; церемония проверок там, где значение очевидно есть. Плати эту цену там, где она покупает больше всего, — в ядре, на границе, в денежной логике. И не плати там, где она покупает мало, — в скрипте на выброс, в прототипе, в тесте.

Возврат к флагу из начала главы: команда, выключившая noUncheckedIndexedAccess после недели войны, могла принять верное решение — если этот флаг на их коде ловил гипотетические дыры ценой сотен ложных | undefined. А могла принять неверное — если он вскрывал настоящие баги, и стоило не выключать его глобально, а локализовать в шумных местах и затянуть остальное. Разница не в флаге. Разница в том, считали ли они цену и выгоду — или просто сдались под объёмом ошибок. Храповик — это не «всегда туже». Это «туже там, где затягивание окупается, и никогда не слабее без записанной причины».

Глазами джависта

В Java вопрос «насколько строгим быть» почти не возникает, и не потому, что джавист его решил, — а потому, что за него решила спецификация языка. Строгость Java задана и зафиксирована: нельзя покрутить null-безопасность (её попросту нет на уровне типов — любая ссылка может быть null), нельзя быть свободнее в скриптах и строже в ядре, нельзя двигать планку по каталогам. javac строг ровно настолько, насколько строг javac, и эта величина одна на всех.

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

Ближайшее, что есть в мире Java к регулятору строгости, — навесные инструменты: Checker Framework, NullAway, Error Prone. Они добавляют проверки, которых в языке нет, — например, отслеживание @Nullable. Но это линтинг поверх компилятора, отдельный слой со своими аннотациями, а не градиент самого javac. Включается он не флагом строгости, а подключением инструмента, и живёт сбоку от языка.

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

Где спектр кусается

Спектр строгости платит за гибкость новыми способами обмануться. Честный их перечень.

Свободный конфиг даёт ложную уверенность. Зелёная сборка под strict: false ощущается так же, как под strict: true, — та же галочка, тот же успокаивающий цвет. Но гарантирует она несравнимо меньше (глава 5). Команда, привыкшая доверять «компилируется», переносит это доверие на код, где оно не заслужено.

Побеги копятся в невидимые дыры. Каждый @ts-ignore, каждый @ts-nocheck, каждый .js-файл без // @ts-check — это локальная капитуляция, которую легко поставить и невозможно увидеть, не ища специально. Без учёта они накапливаются: проект формально строг, а фактически прострелен дырами, о которых никто не помнит. Нужна гигиена — периодически выгребать подавления и считать их число как метрику долга.

@ts-ignore прячет регрессии. Это продолжение предыдущего, но острее. @ts-ignore, поставленный год назад под одну ошибку, сегодня молча глушит совсем другую, появившуюся на той же строке после рефакторинга. Он не самоочищается, поэтому маскирует регрессию, которую @ts-expect-error выдал бы как Unused '@ts-expect-error' directive. Выбор между ними — не стилистика, а разница между «дыра, которая отчитывается» и «дыра, которая лжёт».

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

На швах течёт. Самое опасное. Граница между свободной и строгой зонами — это шов, и через него ложь из свободной зоны втекает в строгую под видом проверенного типа. Значение, родившееся как any в легаси-модуле, пересекает шов и приходит в строгое ядро уже с честным на вид типом Order — которому ничего не соответствует в рантайме. Шов между строгостями — такая же граница доверия, как граница рантайма, и валидировать надо именно там (главы 24–26).

Храповик без принуждения эродирует. Правило «только туже» — это намерение, а намерения под давлением проседают. Без проверки в CI — счётчика подавлений, который не имеет права расти; запрета @ts-ignore в пользу @ts-expect-error; блокировки понижения флагов — храповик расклёпывается, и строгость медленно течёт вниз.

Избыточная строгость в одноразовом коде плодит any. Обратная беда. Если на скрипт-однодневку натянуть максимальный tsconfig без права на послабление, автор не станет честно типизировать то, что завтра удалят, — он насыплет as any, чтобы пройти. Жёсткость, приложенная не к месту, производит ровно те дыры, от которых призвана защищать. Честное послабление безопаснее принудительной строгости, обходимой через any.

strict растёт от версии к версии. Зонтик strict — не застывшее множество. Новые версии TypeScript добавляют под него проверки, и обновление компилятора может вскрыть ошибки в коде, который не менялся (глава 5). Это цена жизни на движущемся языке, и её закладывают в план обновлений.

Включение флага в большой базе вскрывает тысячи ошибок. Повернуть один регулятор на зрелом проекте — не правка конфига, а проект сам по себе. strictNullChecks или noUncheckedIndexedAccess на большой кодовой базе выдадут тысячи ошибок разом, и проглотить их одним коммитом нельзя. Нужен поэтапный план: включить с послаблением по каталогам, чинить поддеревьями, двигать храповик. Иначе верный флаг останется выключенным навсегда — потому что «включить» однажды оказалось неподъёмным.

Калибруй под цену, не гонись за чистотой

Сдвиг, который должен щёлкнуть, противоречит инстинкту, привезённому из мира фиксированной строгости. Там строгость — данность, и единственная добродетель — ей соответствовать; «строже» автоматически значит «лучше». В TypeScript строгость — регулятор, и добродетель не в том, чтобы вывернуть его на максимум, а в том, чтобы поставить в правильную точку на каждом куске кода.

Перепрошивка по шагам. По умолчанию — строгая база: strict плюс те дополнения, что окупаются. Это пол, а не предмет спора в каждом файле. Дальше — осознанно требуй больше там, где корректность дорога: в ядре домена, на границе с внешними данными, в денежной логике — туда максимум, который можешь доказать. И осознанно принимай меньше там, где церемония дороже выгоды: в клее, скриптах, прототипах, тестах — там послабление законно и честнее принудительной строгости, обойдённой через any.

Дисциплина, которая держит это от сползания: храповик движется только туже. Каждое послабление локализовано, прокомментировано причиной, прошло ревью; @ts-expect-error с причиной вместо тихого @ts-ignore; счётчик подавлений в CI, который не растёт. И отдельный глаз — на швы между строгой и свободной зонами: там, где они встречаются, ложь свободной стороны рвётся в строгую под видом проверенного типа, и валидировать надо ровно на шве.

И последнее, что снимает с плеч лишний груз. Зелёная галочка tsc значит разное при разных настройках — это не баг и не повод требовать максимум везде, чтобы галочка значила одно. Это значит, что нужно знать свой регулятор: на какой точке шкалы стоит файл, перед тем как доверять его зелёному. Не гонись за чистотой — она недостижима на живом коде и не нужна на одноразовом. Гонись за строгостью, которая окупает свою цену здесь.

Итог

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

← Глава 30. Вывод против явности Содержание Глава 32. Эстетика TypeScript →