Вы отрефакторили тип фигуры, добавили новый вариант, а 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’и. Вот практичный порядок действий:
- Добавьте
assertNeverв shared utilities. Используйте его в новых switch-блоках. - Когда трогаете существующие switch’и по union’ам — добавляйте guard.
- Для нового кода с дискриминантными union’ами предпочитайте паттерн object-map, если логика чистая и каждый кейс независим.
- Оцените
ts-pattern, если замечаете, что пишете вложенные switch’и или сложную matching-логику больше пары раз.
Цель не в том, чтобы уничтожить switch’и. Цель — заставить компилятор поймать баг раньше ваших пользователей.
Если хотите увидеть это в деле — попробуйте удалить один обработчик из примера AreaMap и посмотрите, как tsc пожалуется. Это красное подчёркивание — разница между тихим пятничным деплоем и pagerduty на выходных.