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

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

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

// Було: 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-ом об'єктів зі спільним літеральним полем (дискримінантом). Бонус: у кожного стану з'являються власні дані, і неможливі комбінації зникають на рівні типів:

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. На цьому будується трюк:

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 взагалі зайвий:

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

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

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

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

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

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-аргументації: зроби неправильні стани непредставимими, і половина захисного коду зникне.

Той самий патерн рятує на межі з 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 знайде всі місця, які треба оновити. Порівняй із тим, скільки це зайняло б через баг-репорти.