Что такое tagged unions и exhaustive check в TypeScript?
Что такое tagged union
Tagged union (или discriminated union, дискриминированное объединение) — это union-тип, состоящий из нескольких вариантов, каждый из которых содержит общее поле с уникальным литеральным значением — «тегом» или дискриминантом. Обычно это поле называют type, kind или tag.
interface Circle {
kind: 'circle';
radius: number;
}
interface Square {
kind: 'square';
side: number;
}
type Shape = Circle | Square;
Ключевое отличие от обычного union — наличие общего поля с литеральным типом, по значению которого TypeScript может однозначно определить, с каким именно вариантом он работает.
Как работает сужение типов
Когда мы проверяем значение дискриминанта в if или switch, TypeScript автоматически сужает (narrowing) тип переменной внутри соответствующей ветки:
function area(shape: Shape): number {
switch (shape.kind) {
case 'circle':
// здесь TS знает, что shape — это Circle
return Math.PI * shape.radius ** 2;
case 'square':
// здесь shape — это Square
return shape.side ** 2;
}
}
Это работает благодаря анализу потока управления (control flow analysis) — компилятор отслеживает, какие значения дискриминанта возможны в каждой точке кода.
Exhaustive check через never
Главная практическая ценность tagged unions раскрывается в связке с проверкой на исчерпывающность. Идея: в default-ветке необработанное значение присваивается переменной типа never. Если все варианты обработаны, TypeScript сузит оставшийся тип до never, и присваивание пройдёт. Если же добавить новый вариант в union и забыть обработать его, компилятор выдаст ошибку.
function assertNever(value: never): never {
throw new Error(`Необработанный случай: ${JSON.stringify(value)}`);
}
function area(shape: Shape): number {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2;
case 'square':
return shape.side ** 2;
default:
// если добавить новый вариант Shape и не обработать его выше,
// здесь возникнет ошибка компиляции
return assertNever(shape);
}
}
Если теперь добавить в Shape третий вариант, например Triangle, компилятор сразу укажет на строку с assertNever(shape), потому что shape в этой точке будет иметь тип Triangle, а не never.
Практическое применение
Этот паттерн особенно полезен в:
- редьюсерах Redux/useReducer, где action — это tagged union по полю
type; - обработке ответов API с разными статусами (
success,error,loading); - реализации конечных автоматов (state machines);
- рекурсивных структурах вроде AST, где узлы различаются по типу.
Exhaustive check превращает добавление нового варианта в задачу, которую невозможно забыть выполнить: TypeScript сам укажет все места, требующие доработки, ещё на этапе компиляции, а не в рантайме у пользователя.
Что хочет услышать интервьюер
Кандидат объясняет, что такое discriminated union и роль общего литерального поля-дискриминанта
Показывает, как TypeScript сужает тип в switch/if по значению дискриминанта
Демонстрирует технику exhaustive check с типом never и функцией-помощником вроде assertNever
Понимает практическую пользу: компилятор ловит забытые кейсы при добавлении нового варианта union
Приводит пример из реальной практики — Redux actions, API-ответы, state machines
Пример: Tagged union и сужение типов через switch
interface Circle {
kind: 'circle';
radius: number;
}
interface Square {
kind: 'square';
side: number;
}
type Shape = Circle | Square;
function area(shape: Shape): number {
switch (shape.kind) {
case 'circle':
// TS сузил тип до Circle
return Math.PI * shape.radius ** 2;
case 'square':
// TS сузил тип до Square
return shape.side ** 2;
}
}
Пример: Exhaustive check с помощью never
interface Circle {
kind: 'circle';
radius: number;
}
interface Square {
kind: 'square';
side: number;
}
// добавили новый вариант
interface Triangle {
kind: 'triangle';
base: number;
height: number;
}
type Shape = Circle | Square | Triangle;
function assertNever(value: never): never {
throw new Error(`Необработанный случай: ${JSON.stringify(value)}`);
}
function area(shape: Shape): number {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2;
case 'square':
return shape.side ** 2;
default:
// ошибка компиляции: Triangle не обработан,
// shape здесь имеет тип Triangle, а не never
return assertNever(shape);
}
}
Типичные ошибки
Путают tagged union с обычным union без общего дискриминанта
Забывают, что дискриминант должен быть литеральным типом (string/number literal), а не просто string
Не используют default-ветку с never, из-за чего пропущенные случаи никак не ловятся компилятором
Подавляют ошибку через as any или as never вместо реальной обработки нового случая
Путают exhaustive check с рантайм-проверкой, хотя это исключительно compile-time гарантия


