Глава 10. null, undefined и честность
Глава 10. null, undefined и честность
Тип сам сказал, что значение может отсутствовать
Вот функция, которая ищет заказ по идентификатору. Её сигнатура честна: заказа может не найтись, и тогда возвращается null.
type OrderId = string;
type Order = {
id: OrderId;
total: number; // сумма в минимальных единицах валюты
status: "created" | "paid" | "shipped" | "delivered" | "cancelled";
items: { sku: string; qty: number }[];
customer?: { phone?: string }; // покупатель не у каждого черновика заказа
};
function findOrder(id: OrderId): Order | null {
return orders.get(id) ?? null;
}
Тип возврата — Order | null. Теперь попробуем им воспользоваться так, как написал бы человек, привыкший, что переменная объявленного типа этот тип и содержит:
const order = findOrder("A-1");
console.log(order.total.toFixed(2));
// ~~~~~ 'order' is possibly 'null'.
Это не предупреждение, которое можно проглядеть, и не подсказка на ховере. Это ошибка типов: файл не компилируется. Компилятор отказывается читать .total у order, пока вы не докажете ему, что order в этой точке — не null. Он буквально не даёт обратиться к полю значения, которое по типу может отсутствовать.
Для человека из мира, где ссылка любого объектного типа всегда может оказаться пустой, это первый сбой интуиции. Там сигнатура Order findOrder(...) ничего не говорит о том, вернётся объект или пустота, — пустота возможна всегда, тип молчит о ней, и обращение к полю компилируется без звука. Обнаружишь беду в рантайме, при разыменовании, далеко от того места, где пустота родилась, и нередко не у себя, а у того, кто вызвал твой код через три слоя. Здесь — наоборот: тип сказал, что значение может отсутствовать, и компилятор не выпускает тебя из этого факта.
Вопрос главы простой. Как получилось, что string в строгом TypeScript не содержит пустоты, а Order | null принуждает обработать её до первого обращения, — и как это покончило с целым классом ошибок, который иначе всплывает из засады.
null и undefined — это типы
Разберём, почему order.total не скомпилировался, с самого основания. В главе 6 тип был множеством значений: boolean — это { true, false }, string — все строки, размеченное объединение из главы 9 — объединение нескольких форм. Продолжим ровно в этой оптике.
null и undefined — два значения JavaScript, означающие отсутствие. И у каждого из них в TypeScript есть одноимённый тип, в котором ровно одно значение. null как тип — это множество { null }. undefined как тип — множество { undefined }. Это такие же литеральные типы, как 42 или "paid", только обитатель у множества один и он — пустота.
const a: null = null; // ок: единственный житель типа null
const b: undefined = undefined; // ок
const c: null = undefined; // ошибка: undefined не входит в множество { null }
Ключевое решение языка — что эти два множества изъяты из всех остальных. Под флагом strictNullChecks (он входит в семейство strict из главы 5) тип string — это все строки и только строки; null и undefined туда не входят. Тип Order — все объекты подходящей формы и ничего, кроме них; пустоты в нём нет.
let name: string = "А-1";
name = null; // ошибка: Type 'null' is not assignable to type 'string'.
name = undefined; // ошибка: Type 'undefined' is not assignable to type 'string'.
Раз пустота не входит в string, то, чтобы значение могло отсутствовать, множество нужно расширить — явно, своей рукой, через объединение (union — тип-сумма, «или то, или другое»):
let maybeName: string | null = "А-1";
maybeName = null; // ок: null теперь житель этого множества
И тогда становится понятно, почему order.total не прошёл. Тип order — это Order | null, то есть множество, в котором кроме объектов-заказов живёт ещё и null. У null нет поля total. Компилятор смотрит на всё множество разом и видит: для одного из его обитателей .total — обращение в пустоту. Поэтому он запрещает доступ ко всему объединению, пока множество не сузится до той части, где total точно есть.
const order = findOrder("A-1"); // тип: Order | null
if (order !== null) {
// здесь null отброшен, тип сузился до Order
console.log(order.total.toFixed(2)); // ок
}
Проверка order !== null — это narrowing, сужение типа (из главы 8): внутри ветки компилятор вычёркивает null из множества, остаётся чистый Order, и .total снова легален. Отсутствие из абстрактной угрозы превратилось в конкретную развилку, которую язык заставил пройти.
Два пустых, а не одно
В предыдущих примерах мелькали оба: null и undefined. Это не синонимы и не два написания одного. Это два разных значения, два разных одноэлементных типа, и язык их различает.
let x: string | null = null;
x = undefined; // ошибка: Type 'undefined' is not assignable to type 'string | null'.
undefined не входит в string | null — его туда не звали. Чтобы разрешить оба вида пустоты, оба надо перечислить: string | null | undefined. Различие не педантизм, у двух пустот разные роли. undefined — это «значения здесь нет»: непереданный аргумент, несуществующее свойство, переменная до присваивания. null — обычно «значение есть, и оно намеренно пустое»: я сам положил сюда пустоту как осмысленный результат.
Важнее всего, что синтаксис ? — необязательное свойство и необязательный параметр — вводит undefined, а не null. Знак вопроса добавляет в тип | undefined и ничего больше:
type Customer = {
id: string;
phone?: string; // то же, что phone: string | undefined
};
const c: Customer = { id: "u-1" };
c.phone; // тип: string | undefined
c.phone = undefined; // ок
c.phone = null; // ошибка: null сюда не входит — было заявлено только undefined
То же с параметром: function ship(order: Order, when?: Date) делает when типом Date | undefined, и звать ship(order) законно. Опуская аргумент, вы получаете undefined, никогда null.
Есть тонкость, которую разводит отдельный флаг exactOptionalPropertyTypes. По умолчанию phone?: string означает две неотличимые ситуации: ключа phone в объекте нет вовсе — и ключ есть, но его значение undefined. Для большинства кода это одно и то же, но не для кода, который перебирает ключи: у { id: "u-1" } проверка "phone" in obj даёт false, а у { id: "u-1", phone: undefined } — true, и Object.keys вернёт у второго лишний ключ "phone". Под exactOptionalPropertyTypes ? означает строго «ключа может не быть», и записать в такое поле явный undefined уже нельзя — для этого пришлось бы написать phone?: string | undefined. Флаг разводит «нет ключа» и «ключ есть, значение undefined» в две разные вещи, какими они в рантайме и являются.
Под strict стоит держать в голове три состояния, а не два: значение есть; значения нет (undefined); значение есть и оно намеренно пустое (null). Сваливать второе и третье в одно — терять информацию, которую тип готов хранить.
Инструменты обращения с отсутствием
Раз отсутствие — часть типа, язык даёт набор операторов, чтобы с ним работать, не сваливаясь каждый раз в развёрнутый if. Все они либо сужают тип, либо аккуратно проваливаются сквозь пустоту.
Первое — сужение проверкой. Здесь есть ловушка выбора оператора. === null отбрасывает только null и оставляет undefined; === undefined — наоборот. А вот нестрогое == null (с двойным равенством) по правилам JavaScript истинно ровно для null и undefined и ложно для всего прочего — это единственная общепринятая идиома, которая ловит обе пустоты разом. Её зеркало != null поэтому ложно ровно для двух пустот, а истинно для всего остального, и потому if (order != null) входит в ветку только тогда, когда значение есть:
function totalOf(order: Order | null | undefined): number {
if (order != null) {
// отброшены и null, и undefined; тип сузился до Order
return order.total;
}
return 0;
}
Второе — опциональная цепочка (optional chaining), оператор ?.. Он читает свойство, только если слева не null и не undefined, иначе вся цепочка коротко замыкается в undefined, не доходя до разыменования пустоты:
function customerPhone(order: Order | null): string | undefined {
return order?.customer?.phone;
// если order — null/undefined, результат сразу undefined,
// обращения к .customer не происходит
}
Заметьте тип результата: даже если phone объявлен как string, выражение даёт string | undefined, потому что цепочка добавляет в исход возможность короткого замыкания. Оператор честен: он не прячет отсутствие, он переносит его в тип результата.
Третье — оператор ?? (nullish coalescing, слияние с пустотой): подставить запасное значение, когда слева пусто. И тут — различие, на котором спотыкаются чаще всего, перенося привычку из JavaScript. Старый || срабатывает на любом ложном значении: "", 0, false, NaN — всё это для него повод взять правую часть. ?? реагирует только на null и undefined:
function shippingNote(order: Order, fee: number): string {
const f1 = fee || 500; // если fee === 0, возьмётся 500 — бесплатная доставка потеряна
const f2 = fee ?? 500; // 0 — законная сумма, останется 0; запасное только для пустоты
return `Доставка: ${f2}`;
}
Для денег, количеств, флагов это разница между корректностью и тихим багом: 0 и false — полноправные значения, а не «отсутствие, к которому надо подставить дефолт». ?? отделяет «значения нет» от «значение есть, но оно ложное», и почти всегда для подстановки дефолта вам нужен именно он.
! — нечестный люк
Есть и четвёртый оператор, стоящий особняком, — non-null assertion, утверждение ненулевости, постфиксный !. Он убирает null и undefined из типа выражения:
const order = findOrder("A-1"); // тип: Order | null
console.log(order!.total); // order! имеет тип Order; компилируется
И тут нужна полная ясность, потому что ! маскируется под собрата ?. и ??, а ведёт себя противоположно. ?. и ?? — настоящие операторы JavaScript: они переживают стирание, проверяют пустоту в рантайме и что-то делают в зависимости от результата. ! не делает в рантайме ничего. Это чистый артефакт типового слоя; после стирания order!.total превращается в order.total, без единой проверки. Всё, что он меняет, — то, во что верит проверяющая программа.
// что компилятор видел // что осталось после стирания
order!.total // order.total
! — это не доказательство, что значения нет пустоты. Это обещание компилятору, ровно той же породы, что as из главы 1: «поверь, здесь не null». Если вы соврали, проверяющий добросовестно подтвердит ложь, а рантайм наткнётся на Cannot read properties of null — в точности тот NPE из засады, ради устранения которого затевался весь strictNullChecks. Это очередная сознательная дыра в состоятельности (unsoundness): язык даёт способ выключить гарантию там, где вы берёте ответственность на себя.
Где ! уместен — там, где вы знаете о значении больше, чем умеет выразить тип, и это знание истинно. Классика — границы, которые система типов не схватывает:
const el = document.getElementById("checkout")!; // тип без ! — HTMLElement | null
// разметку писали мы, элемент с этим id в ней точно есть — тип этого знать не может
А вот перерасход ! — тихая катастрофа. Каждый !, поставленный, чтобы «заткнуть красную подчёркивалку», возвращает в код ровно ту пустоту, от которой strictNullChecks защищает, только теперь молча и под видом проверенного кода. Россыпь восклицательных знаков по файлу — это не строгая типизация, это её имитация: промах на миллиард, аккуратно впущенный обратно через люк.
Глазами джависта
В Java у этой главы нет предмета спора — там проблема в полный рост. Любая ссылка любого объектного типа может быть
null.String s— это «строка илиnull»,Order o— «заказ илиnull», и тип об этом молчит. Компилятор пропускаетs.length()без звука, а пустота прилетает в рантайме какNullPointerException, обычно вдали от места, гдеnullродился. Тони Хоар, придумавший пустую ссылку, назвал её «ошибкой на миллиард долларов» — и носит Java эту ошибку до сих пор в самом ядре системы типов.Заплатки, которые приходят на ум, нуллабельности в систему типов не возвращают.
Optional<Order>— это обёртка-значение, а не свойство типа: она опциональна (никто не обязывает её применять), её легко проигнорировать, методы по-прежнему возвращают голые ссылки, и — вершина иронии — самOptionalможет бытьnull. Аннотации@Nullable/@NonNull— подсказки для внешних анализаторов, javac их не проверяет; в разных библиотеках они свои и необязательны. Это навесное, а не встроенное.TypeScript закрыл ту же дыру внутри системы типов одним флагом:
nullиundefinedизъяты из всех типов, и вернуть их можно только явным| null, после чего компилятор принуждает обработать отсутствие до использования. Целый класс ошибок гасится на этапе проверки.Честный контрапункт, чтобы не выдать это за уникальную победу TS: мир JVM ответ давно знает. В Kotlin нуллабельность в системе типов с самого начала —
Stringнепустой,String?нуллабельный, разыменование?принуждено, и идиомы те же (?.,?:вместо??). А в саму Java через проект Valhalla идут null-restricted типы —Order!как непустой, превью на уровне JDK 28. Так что вопрос не «может ли статически типизированный язык так уметь» — может, и давно. Вопрос в том, что стоковая Java десятилетиями этого не поставляла, а TS поставил с первой строки подstrict.
Где дыра приоткрывается снова
Книга обещает не прятать края, и здесь их несколько. strictNullChecks — мощная гарантия, но у неё есть точные границы, и за каждой пустота возвращается.
Первое и главное — всё держится на флаге. Без strictNullChecks язык откатывается к доджавовской модели: null и undefined снова входят во все типы, let name: string = null компилируется, order.total на Order | null проходит молча. Вся эта глава применима только под strict (или хотя бы под включённым strictNullChecks отдельно). На чужой кодовой базе первое, что стоит проверить, — стоит ли флаг; без него типы лгут о пустоте систематически и тихо.
Второе — !, разобранный выше. Он лжёт по построению и притягивает злоупотребление. Каждый non-null assertion — точка, где гарантия выключена вручную, и компилятор больше за неё не отвечает.
Третье, неочевидное, — индексный доступ. Тип элемента массива или значения по ключу записи компилятор по умолчанию считает всегда присутствующим, хотя в рантайме доступ за границу даёт undefined:
const skus: string[] = ["A", "B"];
const s = skus[10]; // тип: string. в рантайме: undefined
console.log(s.length); // компилируется. рантайм: Cannot read properties of undefined
Здесь strictNullChecks бессилен по умолчанию — нужен отдельный флаг noUncheckedIndexedAccess, под которым skus[10] получает тип string | undefined и снова принуждает к проверке. Это честная плата за эргономику: с флагом каждое обращение по индексу требует обработки пустоты, что в циклах утомительно, поэтому он не входит в strict. Но дыру он закрывает реальную.
Четвёртое — граница рантайма. strictNullChecks гарантирует пустоту только внутри проверенного кода. JSON из сети, ответ платёжного шлюза, ввод формы приходят в рантайм без всяких типов, и total: number в вашем типе ничего не обещает о значении, которое на деле пришло как null:
const order = (await res.json()) as Order; // считаем, что Order
order.total.toFixed(2); // total объявлен number...
// ...а в JSON пришло "total": null → рантайм падает
as (и неявный any от res.json()) — это всё тот же люк: тип утверждён, не проверен. Гарантия испаряется ровно на границе с нетипизированным миром; восстанавливают её там валидацией схемой, к чему мы вернёмся в главе 26.
Пятое, тонкое, — ?. маскирует невозможные состояния. Оператор так удобен, что им затыкают пустоту, которой по-хорошему быть не должно. Если в вашей модели у оплаченного заказа customer обязан существовать, то order.customer?.phone не чинит баг, а прячет его: вместо того чтобы упасть или хотя бы заявить о противоречии, цепочка тихо вернёт undefined и понесёт это дальше. ?. хорош там, где отсутствие законно, и вреден там, где он замазывает состояние, которое модель должна была сделать непредставимым.
Отсутствие — это факт типа, а не сюрприз рантайма
Вот переключатель, который должен щёлкнуть. Перестаньте думать о пустоте как о том, что может случиться с любым значением в любой момент, — о фоновом риске, разлитом по всей программе, против которого спасают оборонительные проверки и привычка. Под strictNullChecks пустота — это не риск, а факт типа: либо она в множестве значений объявлена, и тогда видна в сигнатуре и обязана быть обработана, либо её там нет, и тогда её нет нигде по пути. Третьего, «вообще-то любая ссылка может оказаться пустой», больше не существует.
Из этого следует другая дисциплина. Делайте отсутствие явным там, где оно возможно: Order | null в возврате, phone?: string в форме, string | undefined в результате поиска. Не из педантизма, а потому что объявленное отсутствие компилятор заставит обработать, а необъявленное — тихо взорвётся на границе. Дальше пусть проверяющая программа принуждает: каждое possibly 'null' — это не помеха, а та самая развилка, которую иначе вы нашли бы падением в проде. А ! считайте тем, что он есть, — письменным признанием, что вы вышли за пределы доказательства и берёте этот участок под личную ответственность; редким и заметным, а не россыпью.
И шаг дальше, к главе 29: лучшая обработка отсутствия — та, которой не нужно. Если «отсутствует здесь» — невозможное в вашей предметной области состояние, моделируйте типы так, чтобы его нельзя было выразить. У оплаченного заказа есть покупатель — тогда не customer?: Customer с вечными ?. по всему коду, а разные формы для разных состояний (размеченное объединение из главы 9), где у формы "paid" поле customer обязательно и непусто. Тогда вопрос «а вдруг тут пусто» не встаёт: компилятор не даст собрать оплаченный заказ без покупателя, и ?. не нужен, потому что замазывать нечего. Осознанно разводите «значения нет» и «значение пустое»; не давайте ?. затирать состояния, которых быть не должно.
Итог
Под strictNullChecks нуллабельность принадлежит типу значения, а не рантайму: пустота входит в множество, только если её туда явно впустили, и тогда компилятор гасит до запуска целый класс ошибок — тот самый, что стоковая Java до сих пор отлавливает падением вдали от причины. Кто делает отсутствие видимым в типе, тому больше не приходится бояться его из засады.