# Exhaustive TypeScript: discriminated unions, never і switch, який не дозволяє забути новий кейс

> Додали новий статус замовлення – і UI мовчки показує fallback. Розбираємо, як discriminated unions, never та assertNever перетворюють такі баги на помилки компіляції, і де exhaustive-перевірки стають надмірними.

- Автор: Юра Скиба (https://cookiesoftware.io)
- Опубліковано: 2026-10-01
- Категорія: JavaScript і TypeScript
- Canonical: https://cookiesoftware.io/blog/typescript-exhaustive-checking

---

**Коротка відповідь.** Якщо стан у тебе описаний рядком (`status: string`), компілятор не допоможе, коли з'явиться новий варіант. Опиши стани discriminated union-ом, обробляй їх у switch і додай у default-гілку функцію, яка приймає `never` – і кожен забутий кейс стане помилкою компіляції, а не тихим багом у production. Це три інструменти: union із дискримінантом, тип `never` і дисципліна «жодних мовчазних default».

## Баг, який це все мотивує

Бекенд-команда додає замовленням новий статус `refunded`. Фронтенд про це дізнається так:

```ts
// Було: status: 'pending' | 'paid' | 'shipped' – але в коді просто string
function statusLabel(status: string): string {
  switch (status) {
    case 'pending': return 'Очікує оплати';
    case 'paid': return 'Оплачено';
    case 'shipped': return 'Відправлено';
    default: return 'Невідомий статус'; // 🤫 сюди тихо падає refunded
  }
}
```

Жодної помилки. Ні в компіляторі, ні в runtime, ні в логах. Просто користувач із поверненими грошима бачить «Невідомий статус», а підтримка за тиждень отримує тикет. Це не екзотика – це найтиповіший спосіб, яким розсинхронізуються фронтенд і бекенд.

## Discriminated union замість stringly typed state

Перший крок – описати стани не рядком, а union-ом об'єктів зі спільним літеральним полем (дискримінантом). Бонус: у кожного стану з'являються власні дані, і неможливі комбінації зникають на рівні типів:

```ts
type Order =
  | { status: 'pending'; expiresAt: string }
  | { status: 'paid'; paidAt: string }
  | { status: 'shipped'; trackingNumber: string }
  | { status: 'refunded'; refundedAt: string; reason: string };
```

Тепер `order.trackingNumber` доступний лише після звуження до `'shipped'` – спроба прочитати його в `pending`-гілці не скомпілюється. Це сам по собі великий виграш: «статус + купа nullable-полів» перетворюється на точну модель, де в кожному стані існує лише те, що в ньому можливе.

## never як компайл-тайм сигнал

`never` – тип, у якого немає жодного значення. Якщо switch обробив усі варіанти union-а, у default-гілці TypeScript звужує значення саме до `never`. На цьому будується трюк:

```ts
function assertNever(value: never): never {
  throw new Error(`Необроблений варіант: ${JSON.stringify(value)}`);
}

function statusLabel(order: Order): string {
  switch (order.status) {
    case 'pending': return 'Очікує оплати';
    case 'paid': return 'Оплачено';
    case 'shipped': return `В дорозі (${order.trackingNumber})`;
    case 'refunded': return 'Кошти повернено';
    default: return assertNever(order);
  }
}
```

Поки всі кейси оброблені – `order` у default має тип `never`, і виклик компілюється. Тепер додай у union новий стан `{ status: 'cancelled'; cancelledAt: string }` і подивись, що скаже компілятор:

```
Argument of type '{ status: "cancelled"; cancelledAt: string; }'
is not assignable to parameter of type 'never'.
```

Ось воно. Баг, який раніше жив тижнями в production, тепер живе секунди – до першого `tsc`. Помилка ще й називає конкретний забутий варіант. У CI це означає: фронтенд фізично не задеплоїться, поки хтось не напише гілку для `cancelled`.

## assertNever чи satisfies

Із сучасним TypeScript у тебе два робочі інструменти з різним характером:

**assertNever** – для логіки з гілками (switch, if-ланцюжки). Плюс: працює скрізь, дає і compile-time, і runtime-захист (якщо з API таки прилетить непередбачене – впадеш голосно, а не мовчки). Мінус: трохи церемонії.

**satisfies** – для мапінгів «варіант → значення», де switch взагалі зайвий:

```ts
const STATUS_LABELS = {
  pending: 'Очікує оплати',
  paid: 'Оплачено',
  shipped: 'Відправлено',
  refunded: 'Кошти повернено',
} satisfies Record<Order['status'], string>;
```

Забудеш ключ – компілятор вкаже на відсутню властивість; додаси зайвий – теж помилка. Для словників лейблів, кольорів бейджів, іконок це читається краще за будь-який switch. Правило вибору просте: є поведінка і різні дані по гілках – switch + assertNever; є чиста відповідність «ключ → значення» – об'єкт + satisfies.

Глибше про те, як дженерики підсилюють такі моделі (типізовані API-відповіді, обгортки) – у статті про [TypeScript Generics](/blog/typescript-generics).

## State machine: переходи теж бувають типобезпечними

Exhaustive-перевірки особливо сильні в reducer-ах, де обробляється пара «стан × подія»:

```ts
type OrderEvent =
  | { type: 'PAY' }
  | { type: 'SHIP'; trackingNumber: string }
  | { type: 'REFUND'; reason: string };

function orderReducer(order: Order, event: OrderEvent): Order {
  switch (event.type) {
    case 'PAY':
      if (order.status !== 'pending') return order;
      return { status: 'paid', paidAt: new Date().toISOString() };
    case 'SHIP':
      if (order.status !== 'paid') return order;
      return { status: 'shipped', trackingNumber: event.trackingNumber };
    case 'REFUND':
      if (order.status === 'pending') return order;
      return {
        status: 'refunded',
        refundedAt: new Date().toISOString(),
        reason: event.reason,
      };
    default:
      return assertNever(event);
  }
}
```

Додаєш нову подію – компілятор вимагає рішення по ній. Додаєш новий стан – всі місця, де звужується `order.status`, підсвічують пропуски. Архітектурно це той самий принцип, про який я писав у [розборі senior-аргументації](/blog/senior-react-architecture): зроби неправильні стани непредставимими, і половина захисного коду зникне.

Той самий патерн рятує на межі з API: замапи сирі відповіді бекенду в discriminated union один раз на вході – і далі вся кодова база працює з чесними типами, а не з `string` та надією.

## Де exhaustive switch – надмірність

Чесний розділ, бо інструмент люблять доводити до абсурду:

- **Відкриті множини.** Якщо варіанти визначає сторонній сервіс і вони реально змінюються без твого відома, union бреше. Тоді модель – «відомі варіанти + явний unknown-кейс», і default-гілка там легітимна.
- **Один switch на один union.** Якщо по union-у розкидано п'ять switch-ів у різних файлах, проблема не в exhaustiveness, а в архітектурі – збери поведінку в одне місце (мапінг-об'єкт чи модуль стану).
- **Union на два варіанти, який ніколи не ростиме.** `boolean` під псевдонімом не потребує церемоній.

## Типові помилки

1. Дискримінант не літерал: `{ status: string }` в одному з членів union-а вбиває звуження для всіх.
2. `default: return fallback` «про всяк випадок» поруч із assertNever-культурою в сусідніх файлах – суміш гірша за будь-який один підхід, бо незрозуміло, чому довіряти.
3. `as Order` на межі з API: каст не перевіряє, він обіцяє. Валідуй вхідні дані або чесно працюй із unknown.
4. Копіювання списку статусів у кілька місць замість `Order['status']` – derived-типи існують, щоб список жив один раз.

## Практичне завдання

Знайди у своєму проєкті найбільший switch по рядковому статусу. Перепиши його модель на discriminated union, додай assertNever – і подивись, скільки місць підсвітить компілятор. Кожне з них – точка, де новий статус зламав би UI мовчки. Потім додай вигаданий статус і засікти, за скільки секунд tsc знайде всі місця, які треба оновити. Порівняй із тим, скільки це зайняло б через баг-репорти.
