null, undefined и strictNullChecks
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Пустоту в JavaScript обозначают двумя значениями: null кладут руками, undefined приносит сам движок. TypeScript под флагом strictNullChecks перестаёт считать их частью каждого типа — и на каждое чтение спрашивает: а вы уверены, что там что-то есть? Ответом должна быть проверка, а не восклицательный знак.
В части I обе величины прошли в списке восьми типов, и разница между ними была скорее договорённостью команды, чем правилом языка. TypeScript превращает договорённость в правило: null и undefined — два разных типа, и в объединении они стоят порознь.
Разделение держится не на синтаксисе, а на смысле. null кто-то написал: поле есть, значения в нём осознанно нет. undefined обычно никто не писал — оно возникает там, где значения не оставили.
Три поставщика undefined из примера — отсутствующее свойство, функция без return и непереданный аргумент — дают почти весь undefined, который встречается в жизни. Четвёртый, самый коварный, — выход за границу массива; до него дойдём в конце статьи.
Уклад, который стоит держать во всём проекте: null — «пусто, и так задумано»; undefined — «значения тут никто не оставлял».
До этого флага (и сегодня в любом проекте, где он выключен) null и undefined считались допустимыми значениями любого типа. Написано string — а лежать там могло что угодно из трёх.
Диагностика ровно одна, и стоит она на второй строке: Тип "null" не может быть назначен для типа "string". Без флага её бы не было — а TypeError на третьей строке остался бы точно таким же. Разница только в том, кто первым увидит проблему: вы за десять секунд или пользователь через неделю.
Хотите допустить пустоту — скажите это в типе. Компилятор возражать не станет, но теперь он знает про неё и не даст забыть.
Обратите внимание на счёт: вызова два, падение одно, а диагностика — одна и не про вызовы вовсе. Возможно, "title" имеет значение null сказано про тип, а не про сегодняшний запуск. Компилятор не гадает, что придёт в функцию, — он читает объединение string | null и видит вариант, для которого toUpperCase не существует.
strictNullChecks входит в strict, а strict сегодня ставит tsc --init сам, — поэтому в новом проекте флаг уже работает. Что именно включает strict, подробно разбирается в главе про настройку проекта.
А вот в старом проекте его может не быть, и узнать это проще всего наведением. Если find обещает вам User, а не User | undefined, — флаг выключен: без него undefined и null молча исчезают из объединений, и вся эта статья превращается в набор диагностик, которых вы никогда не увидите.
Сообщений на самом деле два семейства. Первое появляется на присваивании и на аргументе: вы кладёте пустоту туда, где её не ждут. Второе — на чтении: вы обращаетесь к значению, в типе которого пустота есть.
Метод 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. |
Последняя строка таблицы объясняет, почему в одних сообщениях есть имя, а в других — безличное «объект». Цитировать компилятору нечего, если читают прямо у результата вызова.
Смысл у всех шести кодов один: в типе есть вариант, для которого точка после значения не имеет смысла. Разница только в том, сколько вариантов пустоты в объединении и нашлось ли выражению имя.
Диагностика стоит там, где значение читают, а не там, где оно появилось. Тип приехал раньше — иногда из чужого файла.
Первый способ — обычная проверка, та самая, которую вы написали бы и без всякого TypeScript. Компилятор читает поток выполнения и внутри ветки считает тип уже суженным.
Ноль диагностик и ноль падений. Заметьте, что ни одной строки «ради компилятора» здесь нет: без этой проверки код упал бы на втором вызове и в обычном JavaScript. Сам механизм сужения устроен интереснее, чем кажется, — ему посвящена следующая статья.
Второй способ — ?. и ?? из части I. В типах они работают ровно так же, как в браузере, только теперь их видит ещё и компилятор.
Важная деталь строки с len: ?. не устраняет пустоту, а переносит её на результат. Тип выражения user.city?.length — number | undefined, поэтому в переменную number он сам по себе не ляжет; закрывает вопрос уже ??.
Третий способ — одна проверка на обе пустоты сразу. Это единственное место, где нестрогое сравнение из статьи про сравнения и их ловушки не считается дурным тоном: == null истинно ровно для null и undefined и ни для чего больше.
Соблазн написать if (!value) велик, и в девяти случаях из десяти это сработает. Десятый — числа и строки, у которых есть законное «пустое» значение.
Диагностик нет ни в одной строке: компилятор считает такую проверку законным сужением, потому что она и правда отсекает null. Заодно она отсекает 0, и об этом он не предупредит. Подробный разбор проверки на истинность — в следующей статье.
Сужение живёт до тех пор, пока компилятор может поручиться, что значение не изменилось. Свойство объекта в отложенном коде такой гарантии не даёт: между проверкой и вызовом кто угодно мог записать туда null.
Это тот редкий случай, когда компилятор ворчит на работающий код, — и всё же он прав: сегодня выведется РИКО, а после первой же строки, обнуляющей поле, будет TypeError. Лечится не восклицательным знаком, а копией в константу — значение, которое нельзя переприсвоить, сужение не теряет.
Есть способ убрать любую из этих диагностик за один символ. Восклицательный знак после значения означает: «я знаю, что здесь не пусто».
Сравните две последние строки. На четвёртой компилятор предупредил — и получился безобидный NaN. На пятой он промолчал — и вышло падение. Разница между строками ровно в одном символе, и этот символ не сделал код безопаснее: он снял с компилятора обязанность проверять.
! ничего не проверяет и ничего не меняет в скомпилированном коде — из результата он просто исчезает. Это утверждение о типе, родной брат as из статьи про unknown, только короче.
Обещание написано в одном месте, а нарушает его чужой вызов из другого. Пока в каталоге есть товар с нужным номером, всё работает; ошибку принесёт первый запрос с чужим номером — и виноватой будет выглядеть строка внутри titleOf, до которой никто не дотрагивался.
Полностью запрещать ! смысла нет: бывают случаи, где вы действительно знаете больше компилятора. Но каждый такой знак — это заметка «я умнее анализа потока», и её стоит подписывать причиной, как любое неочевидное решение. У typescript-eslint для этого есть отдельное правило, о нём разговор в главе про настройку проекта.
Флаг закрывает объединения, но не индексы. Обращение к элементу массива по умолчанию считается безопасным — компилятор верит, что вы берёте существующий элемент.
Ноль сообщений, честный 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 так, чтобы ! исчез, диагностик не появилось, а на неизвестный номер функция отвечала строкой "товар не найден".
Результат find нужно сначала положить в переменную, а потом проверить. После раннего возврата компилятор сам считает оставшийся тип Product, и обращение к title проходит без единого слова.
Короче — через ?. и ??, одной строкой: return products.find((p) => p.id === id)?.title ?? "товар не найден";. Обе записи честные; выбор между ними — вопрос вкуса и длины ветки «не нашли». Чего делать нельзя, так это менять ! на as Product: это тот же !, только на четыре символа длиннее.
Скидка, которой нет, и скидка в ноль
Скидка у товара необязательная и вдобавок может быть null. Когда её нет, магазин даёт свои 10 процентов. Сейчас товар с честной нулевой скидкой тоже получает эти 10 — почините так, чтобы 0 считался значением, а не пустотой.
Меняется один оператор. || считает пустотой всё ложное, включая 0; ?? — только null и undefined, а обе эти пустоты как раз и записаны в типе поля.
Диагностик не было ни до, ни после: для компилятора обе записи законны. Это полезно помнить — strictNullChecks заставляет обработать пустоту, но не проверяет, правильно ли вы решили, что считать пустотой.
Две пустоты за одну проверку
Функция принимает то, что пришло из формы: строку, null или undefined. Компилятор выдаёт TS18049. Добейтесь, чтобы диагностика исчезла, а функция возвращала обрезанную строку либо "пусто" — и для обеих пустот, и для строки из одних пробелов.
Одно нестрогое сравнение закрывает обе пустоты: text == null истинно для null и undefined и ложно для всего остального, включая пустую строку. Пробельную строку отсекаем уже после обрезки — это условие про данные, а не про типы.
Строгий вариант тоже годится: if (text === null || text === undefined). Он длиннее, зато не требует помнить исключение из правила «никогда не пишите ==».