null, undefined и strictNullChecks

сужение≈ 10 минут

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

Пустоту в JavaScript обозначают двумя значениями: null кладут руками, undefined приносит сам движок. TypeScript под флагом strictNullChecks перестаёт считать их частью каждого типа — и на каждое чтение спрашивает: а вы уверены, что там что-то есть? Ответом должна быть проверка, а не восклицательный знак.

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

В части I обе величины прошли в списке восьми типов, и разница между ними была скорее договорённостью команды, чем правилом языка. TypeScript превращает договорённость в правило: null и undefined — два разных типа, и в объединении они стоят порознь.

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

Разделение держится не на синтаксисе, а на смысле. null кто-то написал: поле есть, значения в нём осознанно нет. undefined обычно никто не писал — оно возникает там, где значения не оставили.

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

Три поставщика undefined из примера — отсутствующее свойство, функция без return и непереданный аргумент — дают почти весь undefined, который встречается в жизни. Четвёртый, самый коварный, — выход за границу массива; до него дойдём в конце статьи.

Уклад, который стоит держать во всём проекте: null — «пусто, и так задумано»; undefined — «значения тут никто не оставлял».

До этого флага (и сегодня в любом проекте, где он выключен) null и undefined считались допустимыми значениями любого типа. Написано string — а лежать там могло что угодно из трёх.

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

Диагностика ровно одна, и стоит она на второй строке: Тип "null" не может быть назначен для типа "string". Без флага её бы не было — а TypeError на третьей строке остался бы точно таким же. Разница только в том, кто первым увидит проблему: вы за десять секунд или пользователь через неделю.

Хотите допустить пустоту — скажите это в типе. Компилятор возражать не станет, но теперь он знает про неё и не даст забыть.

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

Обратите внимание на счёт: вызова два, падение одно, а диагностика — одна и не про вызовы вовсе. Возможно, "title" имеет значение null сказано про тип, а не про сегодняшний запуск. Компилятор не гадает, что придёт в функцию, — он читает объединение string | null и видит вариант, для которого toUpperCase не существует.

strictNullChecks входит в strict, а strict сегодня ставит tsc --init сам, — поэтому в новом проекте флаг уже работает. Что именно включает strict, подробно разбирается в главе про настройку проекта.

А вот в старом проекте его может не быть, и узнать это проще всего наведением. Если find обещает вам User, а не User | undefined, — флаг выключен: без него undefined и null молча исчезают из объединений, и вся эта статья превращается в набор диагностик, которых вы никогда не увидите.

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

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

Метод find возвращает string | undefined — и это не придирка типов, а его настоящее поведение из части I: не нашёл, вернул undefined. Компилятор цитирует имя переменной, чтобы в длинной строке было видно, о ком речь: Возможно, "found" имеет значение undefined.

КодКогда появляетсяТекст
TS2322кладём пустоту в переменную или полеТип "null" не может быть назначен для типа "string".
TS2345то же самое, но в аргументе вызоваАргумент типа "null" нельзя назначить параметру типа "string".
TS18047читаем у значения, в типе которого есть nullВозможно, "user" имеет значение null.
TS18048то же, но в типе undefinedВозможно, "found" имеет значение undefined.
TS18049в типе есть и то и другоеВозможно, "r" имеет значение null или undefined.
TS2531, TS2532, TS2533та же тройка, но у выражения нет имениВозможно, объект равен null / undefined / null или undefined.

Последняя строка таблицы объясняет, почему в одних сообщениях есть имя, а в других — безличное «объект». Цитировать компилятору нечего, если читают прямо у результата вызова.

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

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

Диагностика стоит там, где значение читают, а не там, где оно появилось. Тип приехал раньше — иногда из чужого файла.

Первый способ — обычная проверка, та самая, которую вы написали бы и без всякого TypeScript. Компилятор читает поток выполнения и внутри ветки считает тип уже суженным.

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

Ноль диагностик и ноль падений. Заметьте, что ни одной строки «ради компилятора» здесь нет: без этой проверки код упал бы на втором вызове и в обычном JavaScript. Сам механизм сужения устроен интереснее, чем кажется, — ему посвящена следующая статья.

Второй способ — ?. и ?? из части I. В типах они работают ровно так же, как в браузере, только теперь их видит ещё и компилятор.

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

Важная деталь строки с len: ?. не устраняет пустоту, а переносит её на результат. Тип выражения user.city?.length — number | undefined, поэтому в переменную number он сам по себе не ляжет; закрывает вопрос уже ??.

Третий способ — одна проверка на обе пустоты сразу. Это единственное место, где нестрогое сравнение из статьи про сравнения и их ловушки не считается дурным тоном: == null истинно ровно для null и undefined и ни для чего больше.

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

Соблазн написать if (!value) велик, и в девяти случаях из десяти это сработает. Десятый — числа и строки, у которых есть законное «пустое» значение.

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

Диагностик нет ни в одной строке: компилятор считает такую проверку законным сужением, потому что она и правда отсекает null. Заодно она отсекает 0, и об этом он не предупредит. Подробный разбор проверки на истинность — в следующей статье.

Сужение живёт до тех пор, пока компилятор может поручиться, что значение не изменилось. Свойство объекта в отложенном коде такой гарантии не даёт: между проверкой и вызовом кто угодно мог записать туда null.

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

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

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

Есть способ убрать любую из этих диагностик за один символ. Восклицательный знак после значения означает: «я знаю, что здесь не пусто».

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

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

! ничего не проверяет и ничего не меняет в скомпилированном коде — из результата он просто исчезает. Это утверждение о типе, родной брат as из статьи про unknown, только короче.

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

Обещание написано в одном месте, а нарушает его чужой вызов из другого. Пока в каталоге есть товар с нужным номером, всё работает; ошибку принесёт первый запрос с чужим номером — и виноватой будет выглядеть строка внутри titleOf, до которой никто не дотрагивался.

Полностью запрещать ! смысла нет: бывают случаи, где вы действительно знаете больше компилятора. Но каждый такой знак — это заметка «я умнее анализа потока», и её стоит подписывать причиной, как любое неочевидное решение. У typescript-eslint для этого есть отдельное правило, о нём разговор в главе про настройку проекта.

Флаг закрывает объединения, но не индексы. Обращение к элементу массива по умолчанию считается безопасным — компилятор верит, что вы берёте существующий элемент.

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

Ноль сообщений, честный TypeError. То же самое верно для Object.keys, JSON.parse и всего, что приходит извне. Проверку индексов включает отдельный флаг noUncheckedIndexedAccess, который в strict не входит, — ему посвящена статья «Строгость сверх strict: noUncheckedIndexedAccess и exactOptionalPropertyTypes» в главе о настройке проекта.

  • null и undefined — два разных типа: первое кладут руками, второе приносит отсутствие свойства, возврата или аргумента.
  • strictNullChecks (он же часть strict) убирает пустоту из каждого типа: string означает «точно строка», а «может не быть» пишется как string | null.
  • Диагностики двух семейств: TS2322 и TS2345 — кладём пустоту не туда; TS18047, TS18048, TS18049 и безымянные TS2531–TS2533 — читаем непроверенное.
  • Слово «Возможно» — про тип, а не про текущий запуск: компилятор увидел вариант в объединении, а не предсказал ваше значение.
  • Снимают диагностику проверка, ?. и ??; == null закрывает обе пустоты разом и здесь уместно, а if (value) заодно отсекает 0 и пустую строку.
  • ! — не проверка, а обещание: в скомпилированном коде его нет, поэтому компилятор замолкает, а TypeError остаётся.

1/4Метод find не нашёл подходящей строки, а результат сразу передали в toUpperCase(). Что будет?

2/4В коде было value.toFixed(1) с диагностикой TS18048. Автор дописал восклицательный знак: value!.toFixed(1). Что изменилось?

3/4У поля discount тип number | null, и в объекте лежит 0. Чем отличатся discount || 15 и discount ?? 15?

4/4В массиве const list: number[] = [1, 2, 3] берут list[10] и вызывают у результата toFixed(). Что скажет компилятор под strict?

Убрать восклицательный знак

Функция достаёт название товара по номеру и глушит диагностику восклицательным знаком. На существующем номере всё хорошо, на любом другом — TypeError. Перепишите titleOf так, чтобы ! исчез, диагностик не появилось, а на неизвестный номер функция отвечала строкой "товар не найден".

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

Результат find нужно сначала положить в переменную, а потом проверить. После раннего возврата компилятор сам считает оставшийся тип Product, и обращение к title проходит без единого слова.

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

Короче — через ?. и ??, одной строкой: return products.find((p) => p.id === id)?.title ?? "товар не найден";. Обе записи честные; выбор между ними — вопрос вкуса и длины ветки «не нашли». Чего делать нельзя, так это менять ! на as Product: это тот же !, только на четыре символа длиннее.

Скидка, которой нет, и скидка в ноль

Скидка у товара необязательная и вдобавок может быть null. Когда её нет, магазин даёт свои 10 процентов. Сейчас товар с честной нулевой скидкой тоже получает эти 10 — почините так, чтобы 0 считался значением, а не пустотой.

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

Меняется один оператор. || считает пустотой всё ложное, включая 0; ?? — только null и undefined, а обе эти пустоты как раз и записаны в типе поля.

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

Диагностик не было ни до, ни после: для компилятора обе записи законны. Это полезно помнить — strictNullChecks заставляет обработать пустоту, но не проверяет, правильно ли вы решили, что считать пустотой.

Две пустоты за одну проверку

Функция принимает то, что пришло из формы: строку, null или undefined. Компилятор выдаёт TS18049. Добейтесь, чтобы диагностика исчезла, а функция возвращала обрезанную строку либо "пусто" — и для обеих пустот, и для строки из одних пробелов.

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

Одно нестрогое сравнение закрывает обе пустоты: text == null истинно для null и undefined и ложно для всего остального, включая пустую строку. Пробельную строку отсекаем уже после обрезки — это условие про данные, а не про типы.

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

Строгий вариант тоже годится: if (text === null || text === undefined). Он длиннее, зато не требует помнить исключение из правила «никогда не пишите ==».