Сужение типов и объединения

Что это такое

Объединённый тип говорит, что значение — одно из нескольких возможных: string | number или { status: "loading" } | { status: "ready"; data: Item[] }. Сужение — приём, которым компилятор узнаёт, какой из вариантов перед ним: проверка дискриминанта в if, typeof, switch или собственная функция-страж типа. Вместе они позволяют писать состояния, в которых невозможно ошибиться, а не состояния, которые лишь задокументированы.

Почему это важно

Большая часть защитного кода существует потому, что два факта, обязанные совпадать, хранятся отдельно: логический флаг loading, поле data и строка error, которые могут противоречить друг другу. Объединение делает противоречие непредставимым — пока идёт загрузка, поля с данными нет, и прочитать его по ошибке не получится. В этом разница между проверкой типов, которая документирует ваши дефекты, и той, которая их предотвращает; обычно такая замена убирает больше кода, чем добавляет.

Ключевые идеи

Различайте варианты по литеральному свойству

Дайте каждому варианту литеральное поле с разным значением, и компилятор сможет разделить объединение обычным сравнением. Этот приём покрывает жизненный цикл запроса, отправку формы и любое сообщение, которое приложение передаёт между своими частями.

type LoadState =
  | { status: "loading" }
  | { status: "ready"; items: Item[] }
  | { status: "error"; message: string };

function render(state: LoadState) {
  switch (state.status) {
    case "loading": return "Loading…";
    case "ready": return listItems(state.items);
    case "error": return showError(state.message);
  }
}

Внутри каждой ветки остальные варианты недостижимы, поэтому state.items доступно только там, где оно существует.

Сужайте проверками, которые уже даёт язык

typeof, instanceof, in, Array.isArray и проверка на истинность — все они сужают. Проверки typeof x === "object" самой по себе недостаточно, ведь null — тоже объект; сравнивайте с ним отдельно.

function length(value: string | string[] | null): number {
  if (value === null) return 0;
  return typeof value === "string" ? value.length : value.length;
}

Сделайте исчерпываемость проверкой времени компиляции

Завершите switch по объединению ветвью по умолчанию: она присваивает значение переменной типа never. Если позже появится новый вариант, это присваивание перестанет компилироваться, и компилятор укажет каждое место, требующее правки, вместо того чтобы одно из них молча пропустило случай.

default: {
  const unhandled: never = state;
  throw new Error(`unhandled state: ${JSON.stringify(unhandled)}`);
}

Пишите страж типа, когда проверка повторяется

Функция, которая возвращает value is T, учит компилятор проверке, которую он не может вывести, и держит знание в одном месте. Проверяйте, а не предполагайте: страж, совравший компилятору, переносит сбой со времени компиляции на падение во время выполнения.

Предпочитайте маленькое объединение широкому объекту

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

Практика

Замените три флага одним объединением

Найдите компонент или функцию, которая отслеживает loading, error и data отдельными переменными. Замените их размеченным объединением и switch, который заканчивается проверкой на never.

Готово, когда: код больше не спрашивает, есть ли данные, прежде чем их прочитать, и добавление четвёртого варианта ломает компиляцию, пока вы его не обработаете.

Напишите один переиспользуемый страж типа

Возьмите проверку времени выполнения, которую вы повторяете, — ответ API, который может быть полезной нагрузкой ошибки, значение конфигурации, которое может быть строкой, — и превратите её в функцию-страж.

Готово, когда: страж используется минимум в двух местах, а намеренная передача неверной формы в защищённое значение отклоняется компилятором.

Частые ошибки

  • Проверка необязательного поля через if (x.data) — законное ложное значение вроде 0 или "" принимается за отсутствие; исправить: сужайте по дискриминанту, а не по полезной нагрузке.
  • Забытый null при сужении через typeof — typeof null === "object", и проверка проходит для null; исправить: сравните с null до проверки типа.
  • Приведение через as вместо сужения — компилятор замолкает, а значение во время выполнения не меняется; исправить: используйте дискриминант или проверенный страж типа.
  • switch без проверки исчерпываемости — новый вариант молча игнорируется; исправить: присвойте значение переменной типа never в ветви по умолчанию.
  • Слишком охотный страж, возвращающий true — тип утверждён, а форма неверна, и падение уезжает дальше по потоку; исправить: проверяйте каждое свойство, которое обещает страж.

Что читать дальше

  • Система типов (02-frontend/19-typescript/01-the-type-system) — структурная типизация и вывод типов — механика, на которую опирается сужение.
  • Обобщения (02-frontend/19-typescript/03-generics) — следующий шаг: сохранение связи между типами входа и выхода функции.
  • Паттерны проектирования (01-cs-fundamentals/18-design-patterns) — размеченные объединения — типизированная форма нескольких классических паттернов.