Строгость сверх strict: noUncheckedIndexedAccess и exactOptionalPropertyTypes

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

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

У strict есть край, и за ним остаются две неточности. Тип string[] обещает строку по любому индексу — даже у массива из двух элементов. Необязательное поле page?: number спокойно принимает явное page: undefined, хотя объект с ключом и объект без ключа — это разные объекты. Флаги noUncheckedIndexedAccess и exactOptionalPropertyTypes закрывают обе дыры и берут за это плату.

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

Про эту дыру в учебнике говорилось не раз — в статьях про массивы, про индексные сигнатуры, про strictNullChecks, — и каждый раз с оговоркой «разберём в главе про настройку». Разбираем. Вот она ещё раз.

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

Тип string[] описывает, что лежит внутри, но не сколько элементов там лежит. Длина известна только во время исполнения, поэтому lines[2] компилятор честно считает строкой. Флаг noUncheckedIndexedAccess меняет одно-единственное правило: результат обращения по индексу получает | undefined.

Что при этом происходит, можно потрогать прямо здесь. Песочница учебника компилирует со strict и без этого флага, зато написать союз с undefined руками никто не мешает — диагностики получатся ровно те же.

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

Два разных текста на одну и ту же беду. TS2532: Возможно, объект равен undefined — там, где значение осталось безымянным выражением. TS18048: Возможно, "first" имеет значение undefined — там, где оно попало в переменную и компилятору есть кого назвать по имени.

Флаг трогает только чтение. Записать undefined по индексу он по-прежнему не даст: rows[0] = undefined — это TS2322, «Тип undefined не может быть назначен для типа string».

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

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

Результат исполнения одинаковый, отношение компилятора — разное. У Map.prototype.get в сигнатуре написано V | undefined, и strict требует проверки. У квадратных скобок такой подписи нет, поэтому та же ошибка проходит молча. noUncheckedIndexedAccess просто распространяет привычку get и at на индексный доступ.

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

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

Ни одной диагностики — и с включённым флагом её тоже не будет. for..of в списке не случайно: флаг добавляет undefined к обращению по индексу, а не к типу элемента массива. Перебор, map, filter и entries остаются чистыми, и именно поэтому цена флага оказывается ниже ожидаемой.

А вот цикл со счётчиком он не прощает.

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

Человек видит: i меньше rows.length, значит элемент на месте. Компилятор такую связь не отслеживает — он не считает арифметику и не знает, что длина не изменится внутри тела цикла. Выходов три: перейти на for..of, вынести элемент в переменную и проверить её или поставить !, взяв ответственность на себя.

ПриёмЧто получаетсяКогда
const x = rows[i]; if (x !== undefined)сужение до элементанужен и элемент, и ветка «его нет»
rows[i] ?? запасноеэлемент или значение по умолчаниюесть разумное значение по умолчанию
for (const row of rows)элемент без undefinedнужен перебор, а не конкретный индекс
rows.at(-1)T | undefined всегдапоследний элемент; проверка нужна и без флага
rows[i]!элемент под вашу ответственностьиндекс гарантирован соседним кодом

Кортеж описывает не только содержимое, но и длину, поэтому фиксированные индексы кортежа флаг не трогает. А деструктуризация массива — это то же обращение по индексу, только записанное иначе, и undefined в ней появляется.

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

На кортеж диагностики нет: pair[0] — это string, pair[1] — number, длина известна из типа. На first — знакомая TS18048. Тот же ответ и на вопрос «а rows.pop()?»: методы вроде pop, shift, find и at возвращают T | undefined по своей сигнатуре, независимо от флага.

Второй флаг чинит неточность в необязательных полях. Для strict запись page?: number означает number | undefined, а значит, положить туда undefined руками не запрещено. В системе типов «поля нет» и «поле есть, и в нём undefined» сливаются в одно. В JavaScript они не сливаются нигде.

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

Вот и развилка. Чтение поля и JSON.stringify разницы не видят — оба объекта выглядят одинаково. in и Object.keys видят прекрасно. Любой код, который перебирает ключи, — счётчик заполненных фильтров, сборка строки запроса, сравнение «изменилось ли что-нибудь» — на втором объекте посчитает не то, и найти это по логам почти невозможно: в JSON всё в порядке.

exactOptionalPropertyTypes разводит эти случаи. С ним page?: number читается буквально: либо ключа нет, либо в нём число. Строка с явным undefined становится ошибкой TS2375: Невозможно назначить тип "{ query: string; page: undefined; }" типу "Filter", когда свойство "exactOptionalPropertyTypes" имеет значение "true". Рассмотрите возможность добавления типа "undefined" к типам свойств цели. Отдельное присваивание filter.page = undefined даёт похожую TS2412.

В песочнице учебника этот флаг не включён, поэтому примеры выше выполняются, а не падают на компиляции. Тексты диагностик настоящие: сняты с tsc 6.0.3 с включённым флагом.

Чтение при этом не меняется: filter.page и с флагом остаётся number | undefined — поля-то может не быть. Меняется только запись, и только явная.

Иногда undefined в поле осмысленно: не «значение не задали», а «значение сняли». Под флагом это пишется прямо в типе.

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

cover?: string | undefined под флагом означает «можно не указывать, а можно указать пустым», и это уже договор, записанный в типе, а не догадка читателя. Без флага две записи, cover?: string и cover?: string | undefined, значат ровно одно и то же — потому и кажется, что вторая избыточна.

«Лишний ключ в объекте» звучит безобидно ровно до первого слияния объектов. Форма редактирования профиля: пользователь поменял имя, город не трогал.

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

Город потерян, и виновата одна запись в патче. Spread копирует все собственные ключи объекта, не разбирая значений: city в changes есть, значит undefined ляжет поверх «Дубая». Строка city: undefined выглядит как «поле не меняли», а работает как «поле очистить».

Чинится это не в момент слияния, а в момент сборки патча: ключа, который не меняли, в объекте быть не должно.

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

Условный spread — обычный приём из части I, здесь он получает смысл на уровне типов. С включённым exactOptionalPropertyTypes первый вариант просто не собрался бы: { city: undefined } не подходит под Partial<Profile>, потому что под флагом Partial тоже становится точным.

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

Второй способ ничем не хуже первого, если undefined в патче действительно должен стирать поле: объявите это в типе — city?: string | undefined. Тогда договор один и для компилятора, и для того, кто будет читать код через полгода.

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

Цена у флагов разная. exactOptionalPropertyTypes дешёвый: он всплывает только там, где undefined пишут явно, и в новом коде почти незаметен. noUncheckedIndexedAccess дорогой: на существующем проекте он выдаёт диагностики сотнями, и часть из них — на местах, где элемент действительно всегда есть. Разумный порядок — сначала первый, потом второй, каждый отдельным коммитом: иначе в куче ошибок непонятно, что вы чините.

Быстрее всего noUncheckedIndexedAccess окупается там, где ключ или индекс приходят снаружи: разбор аргументов, словари по строковому ключу, результаты split и регулярных выражений, данные из JSON.parse. Мешает — в коде, который ходит по массиву индексами: сортировки, обмены элементов местами, ручные алгоритмы. Обычный компромисс — включить флаг на весь проект, а в паре таких мест поставить ! с коротким комментарием почему.

ФлагЧего требует
noUncheckedIndexedAccessпроверять результат обращения по индексу
exactOptionalPropertyTypesне писать undefined в необязательное поле
noPropertyAccessFromIndexSignatureк ключам индексной сигнатуры обращаться скобками — TS4111
noImplicitOverrideпомечать переопределение метода словом override — TS4114
noImplicitReturnsвозвращать значение на всех путях функции — TS7030
noUnusedLocals, noUnusedParametersубирать неиспользуемые переменные и параметры

Последняя пара спорна: во время отладки она мешает — закомментировали две строки, и компилятор перестал собирать проект. Ту же работу лучше делает линтер: он ругается предупреждением, а не ошибкой сборки, и умеет прощать имена вроде _unused. Про это — последняя статья главы.

Прежде чем править код, полезно измерить масштаб. Любой флаг можно передать tsc прямо в командной строке, не трогая tsconfig.json: npx tsc --noEmit --noUncheckedIndexedAccess. Компилятор возьмёт настройки проекта, добавит сверху ваш флаг и напечатает список — по нему сразу видно, десяток это мест или полторы тысячи.

Тот же приём годится и для разведки: включить exactOptionalPropertyTypes одной командой и посмотреть, в скольких местах в объекты кладут undefined руками. Часто это два-три места, и все — в функциях обновления.

  • noUncheckedIndexedAccess добавляет | undefined к результату обращения по индексу: rows[i] и dict[key] перестают быть обещанием.
  • Флаг меняет только индексный доступ и только на чтение: for..of, map и фиксированные индексы кортежа остаются чистыми, а запись undefined по-прежнему запрещена.
  • Цикл со счётчиком флаг всё равно не пропустит: связь между i и length компилятор не отслеживает.
  • exactOptionalPropertyTypes разводит «поля нет» и «поле есть, и в нём undefined» — TS2375 при попытке положить undefined в page?: number.
  • В JavaScript эти два объекта различают in и Object.keys, а JSON.stringify — нет; отсюда и патчи, стирающие поля, и сбитые счётчики.
  • Оба флага включаются по одному и по очереди; масштаб работы заранее показывает npx tsc --noEmit с нужным флагом в командной строке.

1/4Массив lines состоит из двух строк. Компилятор со strict, но без дополнительных флагов встречает const line = lines[2]. Что будет?

2/4Флаг noUncheckedIndexedAccess включён, массив объявлен как const rows: string[]. Какая строка НЕ вызовет диагностику?

3/4Поле объявлено как page?: number, включён exactOptionalPropertyTypes. Что будет с объектом { query: "кот", page: undefined }?

4/4Чем объект { query: "кот" } отличается от { query: "кот", page: undefined } во время исполнения?

Заголовок из первой строки

Массив в примере типизирован так, как его видит компилятор с noUncheckedIndexedAccess. Функция title должна убирать решётку из первой строки, а на пустом массиве возвращать "без заголовка". Сейчас она вызывает диагностику и падает на втором вызове — почините, не добавляя ! и as.

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

Обращение по индексу выносится в переменную, и дальше работает обычное сужение по сравнению с undefined — то самое, из статьи про strictNullChecks. Компилятору достаточно одной проверки: после неё head в оставшейся ветке имеет тип string.

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

Короче то же самое пишется через ??: return (rows[0] ?? "без заголовка").replace("# ", ""). Разница в мелочи — во втором варианте replace вызовется и для запасного значения; здесь это безвредно, но в общем случае об этом стоит помнить.

Обновление, которое ничего не стирает

Функция update накладывает патч на настройки. Патч приходит из формы и может содержать ключи со значением undefined — это значит «поле не меняли». Сейчас такой ключ стирает значение. Исправьте update так, чтобы ключи со значением undefined игнорировались.

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

Патч разбирается на пары «ключ — значение», пары с undefined отбрасываются, остальное собирается обратно.

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

Это защита на стороне того, кто принимает патч. С exactOptionalPropertyTypes появляется вторая линия обороны, раньше: вызов update(saved, { fontSize: undefined }) компилятор просто не пропустит.

Счётчик заполненных фильтров

Счётчик показывает, сколько фильтров задал пользователь. Поле, которое не выбирали, приходит из формы как undefined — и счётчик его считает. Почините подсчёт.

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

Считать надо не ключи, а значения, которые действительно есть.

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

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