Вы отрефакторили тип фигуры, добавили новый вариант, а TypeScript остался зелёным. CI прошёл. Деплой ушёл. А потом пользователь попал в рантайм-ветку, которая вернула undefined, и ваше приложение упало в продакшене.

Виновник почти наверняка — оператор switch. У switch в TypeScript нет гарантии исчерпываемости. Добавьте новый член в union — и каждый switch по этому union молча становится неполным. Компилятор не остановит вас. Он даже не предупредит. Вот что люди упускают: TypeScript знает полный набор возможностей, но switch не участвует в проверке исчерпываемости.

Что на самом деле означает проверка исчерпываемости

Исчерпываемость означает, что компилятор может доказать: каждое возможное значение обработано. Языки вроде Rust и OCaml навязывают это на этапе компиляции. TypeScript — нет, по крайней мере для switch.

Рассмотрим дискриминантный union:

type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "rectangle"; width: number; height: number }
  | { kind: "triangle"; base: number; height: number };

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "rectangle":
      return shape.width * shape.height;
    // triangle is missing — and TypeScript is fine with it
  }
}

Это компилируется. И возвращает undefined, когда shape.kind === "triangle". Сигнатура функции обещает number, но реализация его не выдаёт. TypeScript не отмечает отсутствующий кейс, потому что switch-блоки не проверяются на полноту.

Паттерн поиска по объекту

Самая простая замена — обычный объект-маппинг. Каждый ключ ведёт к функции-обработчику. Вы индексируете объект по дискриминанту, и компилятор проверяет, что ключ существует.

const areaHandlers: Record<Shape["kind"], (shape: Shape) => number> = {
  circle: (s) => Math.PI * (s as Extract<Shape, { kind: "circle" }>).radius ** 2,
  rectangle: (s) =>
    (s as Extract<Shape, { kind: "rectangle" }>).width *
    (s as Extract<Shape, { kind: "rectangle" }>).height,
  triangle: (s) =>
    0.5 *
    (s as Extract<Shape, { kind: "triangle" }>).base *
    (s as Extract<Shape, { kind: "triangle" }>).height,
};

function area(shape: Shape): number {
  return areaHandlers[shape.kind](shape);
}

Это лучше. Если добавить "polygon" в Shape, TypeScript пожалуется, что в areaHandlers не хватает ключа. Ошибка укажет на нужное место, и это происходит на этапе компиляции.

Но приведения типов — это некрасиво. Можно сделать лучше.

Типобезопасные обработчики без приведений

Фокус в том, чтобы замаппить каждый вариант на функцию, которая получает суженный тип напрямую. Используется mapped type, чтобы сохранить связь между ключом и конкретным вариантом фигуры.

type ShapeKind = Shape["kind"];

type AreaMap = {
  [K in ShapeKind]: (shape: Extract<Shape, { kind: K }>) => number;
};

const areaHandlers: AreaMap = {
  circle: (s) => Math.PI * s.radius ** 2,
  rectangle: (s) => s.width * s.height,
  triangle: (s) => 0.5 * s.base * s.height,
};

function area(shape: Shape): number {
  const handler = areaHandlers[shape.kind];
  return handler(shape as never);
}

Теперь каждый обработчик получает правильно суженный аргумент. Никаких ручных приведений внутри функций. as never в последней строке — маленький костыль, но он локализован и безопасен. Главная ценность в том, что areaHandlers должен содержать каждый ключ из ShapeKind. Удалите один или добавьте новый вариант в Shape — и сборка сломается.

Проверка исчерпываемости через assert never

Некоторые команды предпочитают оставить switch, но добавить compile-time защиту на исчерпываемость. Это прагматичный компромисс. Вы добавляете ветку default, которая принимает never, заставляя компилятор проверить, что все кейсы обработаны.

function assertNever(x: never): never {
  throw new Error("Unexpected value: " + x);
}

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "rectangle":
      return shape.width * shape.height;
    case "triangle":
      return 0.5 * shape.base * shape.height;
    default:
      return assertNever(shape);
  }
}

Если добавить новый вид фигуры и забыть кейс, ветка default получит не-never значение. TypeScript выдаст ошибку, потому что нельзя присвоить конкретный тип в never. Ошибка укажет на switch — именно туда, куда нужно.

Этот паттерн широко используется в Sentry и других крупных TypeScript-кодбазах. Он не требует менять структуру кода. Он просто заставляет компилятор работать на вас, а не против вас.

Pattern matching со сторонними библиотеками

Если нужно что-то ближе к match в Rust, библиотеки вроде ts-pattern предоставляют pattern matching со встроенной проверкой исчерпываемости.

import { match, P } from "ts-pattern";

function area(shape: Shape): number {
  return match(shape)
    .with({ kind: "circle" }, (s) => Math.PI * s.radius ** 2)
    .with({ kind: "rectangle" }, (s) => s.width * s.height)
    .with({ kind: "triangle" }, (s) => 0.5 * s.base * s.height)
    .exhaustive();
}

Вызов .exhaustive() — ключевой. Если вариант пропущен, TypeScript сообщит об ошибке. Библиотека также поддерживает вложенные паттерны, гарды и вайлдкарды, что позволяет заменить сложные вложенные switch-блоки.

Это мощно, но добавляет зависимость. Для одного дискриминантного union ts-pattern — overkill. Для кодбаз с интенсивным pattern matching это стоит того размера бандла.

Где switch всё ещё выигрывает

Switch не всегда плох. Если кейсам нужен fall-through, или логика императивна с ранними return’ами и побочными эффектами, switch может быть читабельнее lookup-таблицы или цепочки вызовов .with().

Проблема не в синтаксисе. Проблема в отсутствии проверки исчерпываемости. Если используете switch — добавьте guard assertNever. Это две строчки кода, и они закрывают дыру в безопасности.

Как внедрить это без переписывания всего

Не нужно сегодня рефакторить все свои switch’и. Вот практичный порядок действий:

  1. Добавьте assertNever в shared utilities. Используйте его в новых switch-блоках.
  2. Когда трогаете существующие switch’и по union’ам — добавляйте guard.
  3. Для нового кода с дискриминантными union’ами предпочитайте паттерн object-map, если логика чистая и каждый кейс независим.
  4. Оцените ts-pattern, если замечаете, что пишете вложенные switch’и или сложную matching-логику больше пары раз.

Цель не в том, чтобы уничтожить switch’и. Цель — заставить компилятор поймать баг раньше ваших пользователей.

Если хотите увидеть это в деле — попробуйте удалить один обработчик из примера AreaMap и посмотрите, как tsc пожалуется. Это красное подчёркивание — разница между тихим пятничным деплоем и pagerduty на выходных.