never и полный разбор вариантов
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
never — тип, у которого нет ни одного значения: подставить сюда нельзя ни число, ни строку, ни даже undefined. Звучит как бесполезная диковина, но именно из пустоты делают самую практичную страховку в TypeScript. Одна строка в default — и в тот день, когда в объединение добавят новый вариант, сборка сломается ровно там, где вы забыли дописать ветку.
Тип — это множество значений. У string их бесконечно много, у boolean ровно два, у литерального типа "круг" — одно. never продолжает этот ряд до конца: у него их ноль.
Отсюда странное на первый взгляд свойство: переменной типа never нечего присвоить. Совсем.
Две диагностики TS2322 — и обе говорят одно: назначить для типа never не получается ничего. Заметьте, что undefined отвергнут наравне с нулём. «Ничего нет» и «пусто» — разные вещи: у undefined есть своё единственное значение, у never нет ни одного.
| Тип | Сколько у него значений | Где встречается |
|---|---|---|
undefined | одно — само undefined | необязательное поле, переменная без значения |
void | одно, то же самое undefined, но полагаться на него не принято | функция, которая ничего полезного не возвращает |
never | ни одного | функция, которая не возвращает вовсе; остаток объединения, в котором ничего не осталось |
never — не «ошибка» и не «неизвестно». Это честный ответ: множество подходящих значений пусто.
Руками never пишут редко. Гораздо чаще к нему приходит сам компилятор — и вот с этого начинается всё интересное.
Сужение из прошлых статей работает вычитанием: каждая проверка отрезает от объединения часть вариантов, а в следующей ветке остаётся то, что не отрезали. Логичный вопрос — что будет, если отрезать всё.
Ни одной диагностики — и это утверждение, а не отсутствие новостей. Компилятор согласился, что в последней ветке value имеет тип never: разбор полон, лишних вариантов не осталось.
Приём тот же, что в статье про typeof, где тип выясняли присваиванием в null. Только цель у присваивания другая: в null не лезет ничего конкретного, а в never не лезет вообще ничего. Молчание компилятора и означает «здесь пусто».
Теперь добавим в объединение третий вариант — и ни одной другой строки не тронем.
Запуск показывает обе стороны сразу. Компилятор назвал то, что осталось неразобранным, — boolean. А исполнение доказало, что ветка с гордым комментарием «этого не будет» прекрасно достижима: в консоли этого не будет: true.
Если неразобранных вариантов несколько, компилятор перечислит весь остаток — и, как в статье про чтение диагностики, добавит вложенную причину про первый из них.
Пустое множество не добавляет к объединению ничего, поэтому компилятор его просто выбрасывает: string | never — это string. Свойство выглядит формальностью, но именно на нём держится вся статья: как только у остатка отняли последний вариант, от объединения не остаётся даже оболочки.
Соберём всё вместе на размеченном объединении из прошлой статьи. Функция считает площадь, switch разбирает варианты по полю-метке, а в default стоит та самая строка.
Строка выглядит бессмысленной: она объявляет переменную, которой никто не пользуется, в ветке, куда исполнение не заходит. Её работа начнётся не сегодня, а в тот день, когда объединение вырастет.
Этот день наступает. В Shape добавляют треугольник — обычно в другом файле, другой задачей, через полгода. Про area при этом никто не вспоминает.
Обратите внимание, куда указывает диагностика. Не на объявление Shape, где изменение сделали, и не на вызов, где всплывёт последствие, — на строку внутри area, где ветки не хватает. И называет пропущенный вариант целиком, вместе с полями: { kind: "triangle"; base: number; height: number; }.
Проверку ставят не там, где ошибка вероятна, а там, где её нужно заметить. Здесь это одно и то же место.
Запуск показывает цену ошибки. Компилятор предупредил, предупреждение проигнорировали, tsc всё равно выдал JavaScript — и функция, обещавшая число, вернула объект. Дальше его сложат со строкой или умножат на цену, и разбирательство начнётся далеко от area.
Тот же switch, тот же забытый треугольник, но без проверки на never. Функция ничего не возвращает, поэтому и придраться компилятору не к чему.
Пустая консоль и пустой список ошибок — худшее сочетание из возможных. Если бы функция возвращала число, сработала бы страховка из прошлой статьи — компилятор хотя бы сказал TS2366: «В функции отсутствует завершающий оператор return, а тип возвращаемого значения не включает "undefined"». Но и это указание на сигнатуру, а не на пропущенный вариант: какой именно ветки не хватает, придётся выяснять самому.
const impossible: never = shape; работает, но повторять его в каждом switch утомительно, а переменная, которой никто не пользуется, выглядит мусором и притягивает линтеры. Ту же проверку удобнее оформить функцией.
Код диагностики сменился с TS2322 на TS2345 — теперь это не присваивание, а передача аргумента, — но смысл прежний: в параметр типа never нечего передать, пока остаток пуст. Как только вариант забыли, компилятор ловит его на границе вызова.
Слово return перед вызовом не случайно: assertNever объявлен как возвращающий never, а такое значение подходит на место любого другого. Об этом — следующий раздел.
Остаётся вопрос: если компилятор всё проверил, зачем функции ещё и бросать исключение? Ответ — в том, что типов в собранном коде нет.
Ни одной диагностики — и падение при исполнении. as здесь не проверка, а обещание: вы сказали компилятору, что в ответе лежит ClientEvent, и он поверил на слово (разбору таких обещаний посвящена отдельная глава про границы программы). Сервер обещания не давал.
Поэтому assertNever и бросает исключение, а не возвращает заглушку вроде пустой строки. Проверка типов страхует от вашей забывчивости на этапе сборки; throw страхует от чужих данных в рантайме. Заглушка не делает ни того, ни другого — она просто прячет оба случая.
Полезно класть в сообщение сам объект: без него в логах остаётся «необработанный вариант» и ни намёка на то, какой именно.
Почему assertNever объявлен как : never, а не как : void? void означает «вернёт, но смотреть не на что». never — «не вернёт вовсе»: функция либо бросает исключение, либо крутится вечно, но управление в точку вызова не отдаёт.
Строка const b: number = neverReturns() выглядит враньём, но врать тут не о чем. Обещание «здесь будет число» нельзя нарушить, если исполнение до этого места не доходит. Значений типа never не существует, поэтому подставить его можно куда угодно, — и потому return assertNever(shape) проходит в функции, обязанной вернуть число.
Вторая польза важнее. Компилятор вычёркивает из потока управления всё, что стоит после вызова never-функции, — и это работает как сужение.
Без fail последняя строка дала бы TS18048: «Возможно, "user.email" имеет значение undefined». Здесь диагностики нет — компилятор знает, что из ветки с fail выхода не будет, и до return доживает только строка.
Тот же fail, но записанный стрелочной функцией без типа у константы. Внешне ничего не изменилось, вывод типов даже определит возврат как never — а сужение пропадает.
Правило компилятора нарочно осторожное: поток управления обрывает только вызов объявленной функции или константы с явно написанным типом — и тип этот должен быть never. Выведенного never недостаточно: иначе случайное изменение в чужой функции незаметно меняло бы достижимость кода в местах вызова. Лечится одной аннотацией.
Проверка на never держится не на switch, а на том, что до конца функции доживает пустой остаток. Цепочка if годится ровно так же.
А иногда разбор вариантов не нужен вовсе. Если каждому варианту соответствует значение, а не своя логика, полнота достаётся даром — от Record из статьи про индексные сигнатуры.
Код диагностики третий — TS2741, — а поведение то же самое: добавили вариант в Status, и словарь перестал собираться. Подход хорош, пока ветки — это значения; как только внутри варианта появляется своя логика, возвращаются switch и assertNever.
Итог по всем способам разом — что произойдёт в день, когда в объединение добавят вариант.
| Как написан разбор | Что будет, когда объединение вырастет |
|---|---|
switch без default, функция возвращает void | ничего: вариант молча проходит мимо всех веток |
default с заглушкой «на всякий случай» | ничего: заглушка съедает новый вариант |
switch без default, функция возвращает значение | TS2366 на сигнатуре: return не на всех путях. Про пропущенный вариант — ни слова |
default с const impossible: never = x | TS2322 ровно на этой строке, с именем пропущенного варианта |
default с assertNever(x) | TS2345 там же — плюс исключение, если вариант всё-таки придёт извне |
словарь Record<Kind, …> | TS2741: в объекте не хватает ключа |
По имени пропущенный вариант называют только три нижних способа. TS2366 хотя бы шумит, а первые две строки не делают и этого — про ошибку узнает пользователь, а не автор изменения.
never— тип с пустым множеством значений: присвоить переменной такого типа нельзя ничего, дажеundefined.- Компилятор приходит к
neverсам, когда сужение отсекло все варианты объединения; молчаливое присваивание вnever— доказательство, что разбор полон. - Строка
const impossible: never = xвdefaultничего не делает сегодня и даётTS2322с именем пропущенного варианта в тот день, когда объединение вырастет. assertNever(value: never): never— та же проверка функцией:TS2345на сборке и исключение в рантайме, когда чужие данные не совпали с типом.- Тип возврата
neverозначает «не вернётся»: значение подходит куда угодно, а код после вызова компилятор считает недостижимым — но только для объявления функции или константы с явным типом. - Без проверки на
neverзабытый вариант вvoid-функции не даёт ни диагностики, ни следа; для словарей ту же полноту бесплатно обеспечиваетRecord.
1/4В объединении из двух вариантов был switch с default, где стоит const impossible: never = shape;. В объединение добавили третий вариант. Где появится диагностика?
2/4Функция с типом возврата void разбирает switch-ем три варианта из четырёх. Проверки на never нет. Что скажет компилятор?
3/4Компилятор уже доказал, что все варианты разобраны. Зачем тогда assertNever ещё и бросает исключение?
4/4const fail = (message: string) => { throw new Error(message); };, дальше if (user.email === undefined) fail("нет почты"); и обращение к user.email. Что будет?
Заглушка, которая всё прячет
В default стоит возврат «на всякий случай» — и из-за него забытый вариант никак себя не выдаёт: ни диагностики, ни исключения, просто неправильная строка в интерфейсе. Замените заглушку на проверку через assertNever, прочитайте диагностику и допишите недостающую ветку. Ошибка должна выводить x: нет связи (503).
Проверьте себя и в обратную сторону: временно уберите ветку warning — диагностика должна назвать именно её.
Сначала меняем заглушку на assertNever. Компилятор тут же выдаёт TS2345: «Аргумент типа "{ kind: "error"; text: string; code: number; }" нельзя назначить параметру типа "never"» — и прямо называет пропущенный вариант. Дописываем ветку.
Обратите внимание, что поле code доступно только внутри ветки error — сужение по метке никуда не делось. И заметьте разницу в ощущениях: с заглушкой ошибку ищут по неправильной строке на экране, с проверкой её приносит компилятор вместе с адресом.
Словарь вместо разбора
Здесь у каждого статуса своя подпись и больше ничего — логики в ветках нет. Перепишите switch объектом-словарём с типом Record<Status, string>, а затем добавьте в Status вариант "archived" и убедитесь, что компилятор потребовал подпись и для него.
Словарь короче разбора и проверяется на полноту сам, без единой строки про never.
Стоит убрать любую строчку из объекта — и приходит TS2741: «Свойство "archived" отсутствует в типе ... и является обязательным в типе Record<Status, string>».
Заметьте, что исходный switch тоже был не беззащитен: у функции объявлен тип возврата string, поэтому пропущенная ветка дала бы TS2366. Но диагностика указала бы на сигнатуру и не назвала бы пропущенный статус — ради этого имени и ставят assertNever.
Функция, из которой не возвращаются
Конфигурация приходит с необязательным полем, а наружу должна уходить строка. Сейчас компилятор справедливо ругается: TS18048. Добавьте функцию fail, которая бросает исключение и объявлена как возвращающая never, — и диагностика уйдёт без единой проверки в return.
Второй вызов должен падать с внятным сообщением, а не с TypeError про чтение свойства у undefined.
Объявление функции с типом возврата never сообщает компилятору, что после её вызова кода нет. Ветка вычёркивается из потока управления, и до return доживает только строка.
Ровно так же ведёт себя throw прямо в ветке — разница в том, что fail собирает сообщение в одном месте и переиспользуется. Но записать его стрелочной функцией в const без явного типа нельзя: сужение пропадёт, и TS18048 вернётся.