never и полный разбор вариантов

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

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

never — тип, у которого нет ни одного значения: подставить сюда нельзя ни число, ни строку, ни даже undefined. Звучит как бесполезная диковина, но именно из пустоты делают самую практичную страховку в TypeScript. Одна строка в default — и в тот день, когда в объединение добавят новый вариант, сборка сломается ровно там, где вы забыли дописать ветку.

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

Тип — это множество значений. У string их бесконечно много, у boolean ровно два, у литерального типа "круг" — одно. never продолжает этот ряд до конца: у него их ноль.

Отсюда странное на первый взгляд свойство: переменной типа never нечего присвоить. Совсем.

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

Две диагностики TS2322 — и обе говорят одно: назначить для типа never не получается ничего. Заметьте, что undefined отвергнут наравне с нулём. «Ничего нет» и «пусто» — разные вещи: у undefined есть своё единственное значение, у never нет ни одного.

ТипСколько у него значенийГде встречается
undefinedодно — само undefinedнеобязательное поле, переменная без значения
voidодно, то же самое undefined, но полагаться на него не принятофункция, которая ничего полезного не возвращает
neverни одногофункция, которая не возвращает вовсе; остаток объединения, в котором ничего не осталось

never — не «ошибка» и не «неизвестно». Это честный ответ: множество подходящих значений пусто.

Руками never пишут редко. Гораздо чаще к нему приходит сам компилятор — и вот с этого начинается всё интересное.

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

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

Ни одной диагностики — и это утверждение, а не отсутствие новостей. Компилятор согласился, что в последней ветке value имеет тип never: разбор полон, лишних вариантов не осталось.

Приём тот же, что в статье про typeof, где тип выясняли присваиванием в null. Только цель у присваивания другая: в null не лезет ничего конкретного, а в never не лезет вообще ничего. Молчание компилятора и означает «здесь пусто».

Теперь добавим в объединение третий вариант — и ни одной другой строки не тронем.

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

Запуск показывает обе стороны сразу. Компилятор назвал то, что осталось неразобранным, — boolean. А исполнение доказало, что ветка с гордым комментарием «этого не будет» прекрасно достижима: в консоли этого не будет: true.

Если неразобранных вариантов несколько, компилятор перечислит весь остаток — и, как в статье про чтение диагностики, добавит вложенную причину про первый из них.

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

Пустое множество не добавляет к объединению ничего, поэтому компилятор его просто выбрасывает: string | never — это string. Свойство выглядит формальностью, но именно на нём держится вся статья: как только у остатка отняли последний вариант, от объединения не остаётся даже оболочки.

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

Соберём всё вместе на размеченном объединении из прошлой статьи. Функция считает площадь, switch разбирает варианты по полю-метке, а в default стоит та самая строка.

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

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

Этот день наступает. В Shape добавляют треугольник — обычно в другом файле, другой задачей, через полгода. Про area при этом никто не вспоминает.

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

Обратите внимание, куда указывает диагностика. Не на объявление Shape, где изменение сделали, и не на вызов, где всплывёт последствие, — на строку внутри area, где ветки не хватает. И называет пропущенный вариант целиком, вместе с полями: { kind: "triangle"; base: number; height: number; }.

Проверку ставят не там, где ошибка вероятна, а там, где её нужно заметить. Здесь это одно и то же место.

Запуск показывает цену ошибки. Компилятор предупредил, предупреждение проигнорировали, tsc всё равно выдал JavaScript — и функция, обещавшая число, вернула объект. Дальше его сложат со строкой или умножат на цену, и разбирательство начнётся далеко от area.

Тот же switch, тот же забытый треугольник, но без проверки на never. Функция ничего не возвращает, поэтому и придраться компилятору не к чему.

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

Пустая консоль и пустой список ошибок — худшее сочетание из возможных. Если бы функция возвращала число, сработала бы страховка из прошлой статьи — компилятор хотя бы сказал TS2366: «В функции отсутствует завершающий оператор return, а тип возвращаемого значения не включает "undefined"». Но и это указание на сигнатуру, а не на пропущенный вариант: какой именно ветки не хватает, придётся выяснять самому.

const impossible: never = shape; работает, но повторять его в каждом switch утомительно, а переменная, которой никто не пользуется, выглядит мусором и притягивает линтеры. Ту же проверку удобнее оформить функцией.

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

Код диагностики сменился с TS2322 на TS2345 — теперь это не присваивание, а передача аргумента, — но смысл прежний: в параметр типа never нечего передать, пока остаток пуст. Как только вариант забыли, компилятор ловит его на границе вызова.

Слово return перед вызовом не случайно: assertNever объявлен как возвращающий never, а такое значение подходит на место любого другого. Об этом — следующий раздел.

Остаётся вопрос: если компилятор всё проверил, зачем функции ещё и бросать исключение? Ответ — в том, что типов в собранном коде нет.

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

Ни одной диагностики — и падение при исполнении. as здесь не проверка, а обещание: вы сказали компилятору, что в ответе лежит ClientEvent, и он поверил на слово (разбору таких обещаний посвящена отдельная глава про границы программы). Сервер обещания не давал.

Поэтому assertNever и бросает исключение, а не возвращает заглушку вроде пустой строки. Проверка типов страхует от вашей забывчивости на этапе сборки; throw страхует от чужих данных в рантайме. Заглушка не делает ни того, ни другого — она просто прячет оба случая.

Полезно класть в сообщение сам объект: без него в логах остаётся «необработанный вариант» и ни намёка на то, какой именно.

Почему assertNever объявлен как : never, а не как : void? void означает «вернёт, но смотреть не на что». never — «не вернёт вовсе»: функция либо бросает исключение, либо крутится вечно, но управление в точку вызова не отдаёт.

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

Строка const b: number = neverReturns() выглядит враньём, но врать тут не о чем. Обещание «здесь будет число» нельзя нарушить, если исполнение до этого места не доходит. Значений типа never не существует, поэтому подставить его можно куда угодно, — и потому return assertNever(shape) проходит в функции, обязанной вернуть число.

Вторая польза важнее. Компилятор вычёркивает из потока управления всё, что стоит после вызова never-функции, — и это работает как сужение.

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

Без fail последняя строка дала бы TS18048: «Возможно, "user.email" имеет значение undefined». Здесь диагностики нет — компилятор знает, что из ветки с fail выхода не будет, и до return доживает только строка.

Тот же fail, но записанный стрелочной функцией без типа у константы. Внешне ничего не изменилось, вывод типов даже определит возврат как never — а сужение пропадает.

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

Правило компилятора нарочно осторожное: поток управления обрывает только вызов объявленной функции или константы с явно написанным типом — и тип этот должен быть never. Выведенного never недостаточно: иначе случайное изменение в чужой функции незаметно меняло бы достижимость кода в местах вызова. Лечится одной аннотацией.

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

Проверка на never держится не на switch, а на том, что до конца функции доживает пустой остаток. Цепочка if годится ровно так же.

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

А иногда разбор вариантов не нужен вовсе. Если каждому варианту соответствует значение, а не своя логика, полнота достаётся даром — от Record из статьи про индексные сигнатуры.

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

Код диагностики третий — TS2741, — а поведение то же самое: добавили вариант в Status, и словарь перестал собираться. Подход хорош, пока ветки — это значения; как только внутри варианта появляется своя логика, возвращаются switch и assertNever.

Итог по всем способам разом — что произойдёт в день, когда в объединение добавят вариант.

Как написан разборЧто будет, когда объединение вырастет
switch без default, функция возвращает voidничего: вариант молча проходит мимо всех веток
default с заглушкой «на всякий случай»ничего: заглушка съедает новый вариант
switch без default, функция возвращает значениеTS2366 на сигнатуре: return не на всех путях. Про пропущенный вариант — ни слова
default с const impossible: never = xTS2322 ровно на этой строке, с именем пропущенного варианта
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).

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

Проверьте себя и в обратную сторону: временно уберите ветку warning — диагностика должна назвать именно её.

Сначала меняем заглушку на assertNever. Компилятор тут же выдаёт TS2345: «Аргумент типа "{ kind: "error"; text: string; code: number; }" нельзя назначить параметру типа "never"» — и прямо называет пропущенный вариант. Дописываем ветку.

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

Обратите внимание, что поле code доступно только внутри ветки error — сужение по метке никуда не делось. И заметьте разницу в ощущениях: с заглушкой ошибку ищут по неправильной строке на экране, с проверкой её приносит компилятор вместе с адресом.

Словарь вместо разбора

Здесь у каждого статуса своя подпись и больше ничего — логики в ветках нет. Перепишите switch объектом-словарём с типом Record<Status, string>, а затем добавьте в Status вариант "archived" и убедитесь, что компилятор потребовал подпись и для него.

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

Словарь короче разбора и проверяется на полноту сам, без единой строки про never.

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

Стоит убрать любую строчку из объекта — и приходит TS2741: «Свойство "archived" отсутствует в типе ... и является обязательным в типе Record<Status, string>».

Заметьте, что исходный switch тоже был не беззащитен: у функции объявлен тип возврата string, поэтому пропущенная ветка дала бы TS2366. Но диагностика указала бы на сигнатуру и не назвала бы пропущенный статус — ради этого имени и ставят assertNever.

Функция, из которой не возвращаются

Конфигурация приходит с необязательным полем, а наружу должна уходить строка. Сейчас компилятор справедливо ругается: TS18048. Добавьте функцию fail, которая бросает исключение и объявлена как возвращающая never, — и диагностика уйдёт без единой проверки в return.

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

Второй вызов должен падать с внятным сообщением, а не с TypeError про чтение свойства у undefined.

Объявление функции с типом возврата never сообщает компилятору, что после её вызова кода нет. Ветка вычёркивается из потока управления, и до return доживает только строка.

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

Ровно так же ведёт себя throw прямо в ветке — разница в том, что fail собирает сообщение в одном месте и переиспользуется. Но записать его стрелочной функцией в const без явного типа нельзя: сужение пропадёт, и TS18048 вернётся.