Строгость сверх strict: noUncheckedIndexedAccess и exactOptionalPropertyTypes
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
У strict есть край, и за ним остаются две неточности. Тип string[] обещает строку по любому индексу — даже у массива из двух элементов. Необязательное поле page?: number спокойно принимает явное page: undefined, хотя объект с ключом и объект без ключа — это разные объекты. Флаги noUncheckedIndexedAccess и exactOptionalPropertyTypes закрывают обе дыры и берут за это плату.
Про эту дыру в учебнике говорилось не раз — в статьях про массивы, про индексные сигнатуры, про strictNullChecks, — и каждый раз с оговоркой «разберём в главе про настройку». Разбираем. Вот она ещё раз.
Тип string[] описывает, что лежит внутри, но не сколько элементов там лежит. Длина известна только во время исполнения, поэтому lines[2] компилятор честно считает строкой. Флаг noUncheckedIndexedAccess меняет одно-единственное правило: результат обращения по индексу получает | undefined.
Что при этом происходит, можно потрогать прямо здесь. Песочница учебника компилирует со strict и без этого флага, зато написать союз с undefined руками никто не мешает — диагностики получатся ровно те же.
Два разных текста на одну и ту же беду. TS2532: Возможно, объект равен undefined — там, где значение осталось безымянным выражением. TS18048: Возможно, "first" имеет значение undefined — там, где оно попало в переменную и компилятору есть кого назвать по имени.
Флаг трогает только чтение. Записать undefined по индексу он по-прежнему не даст: rows[0] = undefined — это TS2322, «Тип undefined не может быть назначен для типа string».
Массивы — половина дела. Индексные сигнатуры и Record флаг задевает сильнее: у них вообще нет способа узнать, есть ли ключ. Причём рядом, в той же стандартной библиотеке, живёт коллекция, которая честна без всяких флагов.
Результат исполнения одинаковый, отношение компилятора — разное. У Map.prototype.get в сигнатуре написано V | undefined, и strict требует проверки. У квадратных скобок такой подписи нет, поэтому та же ошибка проходит молча. noUncheckedIndexedAccess просто распространяет привычку get и at на индексный доступ.
Первое впечатление от флага — «теперь проверка на каждый чих». Оно преувеличено: четыре приёма закрывают почти всё, и три из них вы пишете не задумываясь ещё с части I.
Ни одной диагностики — и с включённым флагом её тоже не будет. for..of в списке не случайно: флаг добавляет undefined к обращению по индексу, а не к типу элемента массива. Перебор, map, filter и entries остаются чистыми, и именно поэтому цена флага оказывается ниже ожидаемой.
А вот цикл со счётчиком он не прощает.
Человек видит: 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 в ней появляется.
На кортеж диагностики нет: pair[0] — это string, pair[1] — number, длина известна из типа. На first — знакомая TS18048. Тот же ответ и на вопрос «а rows.pop()?»: методы вроде pop, shift, find и at возвращают T | undefined по своей сигнатуре, независимо от флага.
Второй флаг чинит неточность в необязательных полях. Для strict запись page?: number означает number | undefined, а значит, положить туда undefined руками не запрещено. В системе типов «поля нет» и «поле есть, и в нём undefined» сливаются в одно. В JavaScript они не сливаются нигде.
Вот и развилка. Чтение поля и 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 в поле осмысленно: не «значение не задали», а «значение сняли». Под флагом это пишется прямо в типе.
cover?: string | undefined под флагом означает «можно не указывать, а можно указать пустым», и это уже договор, записанный в типе, а не догадка читателя. Без флага две записи, cover?: string и cover?: string | undefined, значат ровно одно и то же — потому и кажется, что вторая избыточна.
«Лишний ключ в объекте» звучит безобидно ровно до первого слияния объектов. Форма редактирования профиля: пользователь поменял имя, город не трогал.
Город потерян, и виновата одна запись в патче. Spread копирует все собственные ключи объекта, не разбирая значений: city в changes есть, значит undefined ляжет поверх «Дубая». Строка city: undefined выглядит как «поле не меняли», а работает как «поле очистить».
Чинится это не в момент слияния, а в момент сборки патча: ключа, который не меняли, в объекте быть не должно.
Условный 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.
Обращение по индексу выносится в переменную, и дальше работает обычное сужение по сравнению с undefined — то самое, из статьи про strictNullChecks. Компилятору достаточно одной проверки: после неё head в оставшейся ветке имеет тип string.
Короче то же самое пишется через ??: return (rows[0] ?? "без заголовка").replace("# ", ""). Разница в мелочи — во втором варианте replace вызовется и для запасного значения; здесь это безвредно, но в общем случае об этом стоит помнить.
Обновление, которое ничего не стирает
Функция update накладывает патч на настройки. Патч приходит из формы и может содержать ключи со значением undefined — это значит «поле не меняли». Сейчас такой ключ стирает значение. Исправьте update так, чтобы ключи со значением undefined игнорировались.
Патч разбирается на пары «ключ — значение», пары с undefined отбрасываются, остальное собирается обратно.
Это защита на стороне того, кто принимает патч. С exactOptionalPropertyTypes появляется вторая линия обороны, раньше: вызов update(saved, { fontSize: undefined }) компилятор просто не пропустит.
Счётчик заполненных фильтров
Счётчик показывает, сколько фильтров задал пользователь. Поле, которое не выбирали, приходит из формы как undefined — и счётчик его считает. Почините подсчёт.
Считать надо не ключи, а значения, которые действительно есть.
Задача маленькая, но ошибка из неё — настоящая: ровно так «сбрасывать фильтры» перестаёт гасить бейдж с числом. Заметить её тяжело, потому что в JSON.stringify лишнего ключа не видно.