Перейти к содержимому
Теория · Сужение типов · урок 9 из 12

Проверка полноты через never

Однажды на сортировочную ленту приезжают посылки нового вида — длинные тубусы. Для конвертов и для банок есть свои жёлоба, а для тубусов нет. Тубус проезжает мимо всех жёлобов и падает на пол в конце ленты, и никто этого не замечает.

В программе так бывает, когда в объединение добавляют вариант. Фигур стало три, а switch в функции label по-прежнему разбирает две:

TypeScript
type Shape =
    | { kind: "circle"; r: number }
    | { kind: "square"; side: number }
    | { kind: "triangle"; side: number };

function label(s: Shape) {
    switch (s.kind) {
        case "circle":
            return "circle, r = " + s.r;
        case "square":
            return "square, side = " + s.side;
    }
}

console.log(label({ kind: "triangle", side: 2 })); // undefined

tsc молчит. Треугольник проходит мимо обоих case, функция доходит до конца, и программа печатает undefined. Для функции без аннотации результата это законно: tsc выводит для неё тип string | undefined. В большом проекте такой switch не один, и найти вручную все места, где забыт новый вариант, трудно.

Что остаётся в default

Вспомните тип never: так обозначают значение, которого не бывает. В switch по признаку каждый case убирает свой вариант из типа s. Если разобраны все варианты, в ветку default не может попасть ни одна фигура, и у s там тип never. На этом построена проверка полноты: компилятор убеждается, что switch разобрал каждый вариант объединения:

TypeScript
type Shape =
    | { kind: "circle"; r: number }
    | { kind: "square"; side: number };

function label(s: Shape) {
    switch (s.kind) {
        case "circle":
            return "circle, r = " + s.r;
        case "square":
            return "square, side = " + s.side;
        default:
            const rest: never = s;
            return rest;
    }
}

Давайте разберём пример по строкам:

  1. case "circle" и case "square" разбирают оба варианта объединения.
  2. В default у s тип never: после двух case вариантов не осталось.
  3. const rest: never = s; компилируется: значение типа never подходит к переменной типа never.
  4. return rest даёт функции ответ и на этом пути. Пока все варианты разобраны, программа до этой строки не дойдёт.

Строка с never — контрольный жёлоб в конце ленты. Пока для каждого вида посылок есть свой жёлоб, в контрольный ничего не падает.

Так же работает switch прямо по объединению литералов. Например, если у p тип "card" | "cash", каждый case убирает из типа p свой литерал, и в default остаётся never.

Забытый вариант

Теперь добавьте в Shape третий вариант { kind: "triangle"; side: number } и ничего не меняйте в label. В default теперь попадает треугольник, а треугольник к типу never не подходит:

TEXT
error TS2322: Type '{ kind: "triangle"; side: number; }' is not assignable to type 'never'.

tsc показывает строку с never и называет вариант, который забыли разобрать. Добавьте case "triangle", и ошибка исчезнет. Так компилятор найдёт каждый switch в проекте, где нужен новый case.

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

У функции с аннотацией результата есть и вторая подсказка. Если написать function label(s: Shape): string без default, tsc заметит путь без return:

TEXT
error TS2366: Function lacks ending return statement and return type does not include 'undefined'.

Но вариант в этом сообщении не назван, а функция, которая ничего не возвращает, такой ошибки не получит вовсе. Проверка через never работает в любой функции.

Этот вопрос часто задают на собеседовании: как сделать, чтобы компилятор сам нашёл необработанный вариант объединения. Ответ: в default присвоить значение переменной типа never.

Частая ошибка

В default вернули запасной ответ:

TypeScript
type Shape =
    | { kind: "circle"; r: number }
    | { kind: "square"; side: number }
    | { kind: "triangle"; side: number };

function label(s: Shape) {
    switch (s.kind) {
        case "circle":
            return "circle, r = " + s.r;
        case "square":
            return "square, side = " + s.side;
        default:
            return "some shape"; // ✗
    }
}

tsc молчит: "some shape" — законная строка, и в default можно положить что угодно. Треугольник получит запасной ответ, и о новом варианте никто не узнает. Такой default ловит все неразобранные варианты, в том числе забытые. Оставьте в default проверку через never, а для каждого варианта напишите свой case.

Главное

  • Когда switch разобрал все варианты объединения, в default у значения тип never.
  • const rest: never = s; в default компилируется, только пока разобраны все варианты.
  • Забытый вариант tsc покажет на этой строке: Type '{ kind: "triangle"; side: number; }' is not assignable to type 'never'.
  • Запасной ответ в default прячет забытые варианты от tsc.