Что такое tagged unions и exhaustive check в TypeScript?

SeniorTypeScript · Frontend·Обновлено 6 сентября 2026
Коротко
Tagged union (дискриминированное объединение) — это union из нескольких типов с общим литеральным полем-дискриминантом, по которому TypeScript сужает тип в switch/if. Exhaustive check — приём с типом never в default-ветке, который гарантирует на этапе компиляции, что обработаны все варианты union.

Что такое 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 гарантия

Лучшие курсы по теме

изображение курса

Angular

Антон Ларичев
AI-тренажерыAI-тренажеры
Гарантия
Бонусы
иконка звёздочки рейтинга4.6
3 999 ₽ 6 990 ₽
Подробнее
изображение курса

Vue 3 и Pinia

Антон Ларичев
AI-тренажерыAI-тренажеры
Практика в студииПрактика в студии
Гарантия
Бонусы
иконка звёздочки рейтинга4.8
3 999 ₽ 6 990 ₽
Подробнее
изображение курса

Feature-Sliced Design

Антон Ларичев
AI-тренажерыAI-тренажеры
Практика в студииПрактика в студии
Гарантия
Бонусы
иконка звёздочки рейтинга4.6
3 999 ₽ 6 990 ₽
Подробнее