Kodokon kodokon.com

never, полнота и продвинутые размеченные объединения

Ты смоделируешь свои состояния размеченными объединениями и закрепишь их полноту с помощью типа never.

10 мин · 3 вопросов

Открыть этот урок в Kodokon

never - это пустой тип: ни одно значение ему не принадлежит. Он появляется, когда компилятор доказывает, что путь выполнения невозможен - например, после того как исчерпаны все члены объединения. Это свойство превращает его в инструмент проектирования: если ты добился, чтобы в ветке default у switch компилятор говорил never, у тебя есть статическое доказательство, что обработаны все случаи. Это основа проверки полноты.

С размеченными объединениями ты познакомился в прошлом модуле; теперь пора превратить их в полноценный инструмент проектирования. По сравнению с одним интерфейсом, набитым необязательными полями, размеченное объединение делает невозможные состояния непредставимыми: у тебя не может быть одновременно data и error или успеха без данных. Это основной паттерн для состояний загрузки, результатов операций и событийных сообщений.

TYPESCRIPT
type FetchState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: Error };

function render(state: FetchState<string[]>): string {
  switch (state.status) {
    case "idle": return "Waiting";
    case "loading": return "Loading…";
    case "success": return state.data.join(", ");
    case "error": return state.error.message;
  }
}
data существует только в варианте success: больше нигде до неё не добраться.

Со стражем assertNever, вызываемым в ветке default, ты уже сталкивался; теперь разберём точно, как он работает. Пока покрыты все случаи, его параметр имеет там тип never, и всё компилируется. Добавь вариант в объединение, не обновив switch, - и забытый вариант перестанет быть совместимым с never: ошибка компиляции, а в сообщении - имя пропущенного случая. Ты превращаешь баг в продакшене в отказ сборки.

TYPESCRIPT
type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; side: number }
  | { kind: "rect"; width: number; height: number };

function assertNever(value: never): never {
  throw new Error("Unhandled case: " + String(value));
}

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle": return Math.PI * shape.radius ** 2;
    case "square": return shape.side ** 2;
    case "rect": return shape.width * shape.height;
    default: return assertNever(shape);
  }
}
Добавление варианта в Shape ломает компиляцию area.

Две продвинутые тонкости заслуживают твоего внимания. Во-первых, сужение по дискриминанту распространяется на вложенные структуры, только если ты проверяешь сам охватывающий объект; деструктуризация одного лишь дискриминанта рвёт эту связь (кроме случаев совместной деструктуризации, которые поддерживаются с TypeScript 4.6). Во-вторых, never поглощается в объединениях (string | never - это просто string): именно это позволяет условным типам отсеивать варианты, как делает Exclude.

TYPESCRIPT
type Event =
  | { type: "click"; x: number; y: number }
  | { type: "keypress"; key: string };

type EventOf<T extends Event["type"]> =
  Extract<Event, { type: T }>;

type ClickEvent = EventOf<"click">;

function handle<T extends Event["type"]>(
  type: T,
  handler: (event: EventOf<T>) => void
): void {
  /* register the handler */
}

handle("click", (e) => console.log(e.x, e.y));
Extract фильтрует объединение по дискриминанту: обработчик получает точный тип.

Проверка знаний

Убедись, что запомнил ключевые моменты этого урока.

  1. Почему для состояния загрузки размеченное объединение лучше интерфейса с необязательными полями?
    • Оно занимает меньше памяти во время выполнения
    • Оно делает непредставимыми несогласованные комбинации (data и error одновременно)
    • Оно избавляет от написания switch
    • Оно обязательно в строгом режиме
  2. Что произойдёт, если добавить вариант в Shape, не изменив switch с assertNever?
    • При каждом вызове area будет выбрасываться исключение
    • Код скомпилируется, но новый вариант вернёт undefined
    • Компиляция упадёт, потому что забытый вариант не совместим с never
  3. Какой тип получится из string | never?
    • never
    • string
    • unknown
    • string | undefined