Коротка відповідь. Якщо стан у тебе описаний рядком (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під псевдонімом не потребує церемоній.
Типові помилки
- Дискримінант не літерал:
{ status: string }в одному з членів union-а вбиває звуження для всіх. default: return fallback«про всяк випадок» поруч із assertNever-культурою в сусідніх файлах – суміш гірша за будь-який один підхід, бо незрозуміло, чому довіряти.as Orderна межі з API: каст не перевіряє, він обіцяє. Валідуй вхідні дані або чесно працюй із unknown.- Копіювання списку статусів у кілька місць замість
Order['status']– derived-типи існують, щоб список жив один раз.
Практичне завдання
Знайди у своєму проєкті найбільший switch по рядковому статусу. Перепиши його модель на discriminated union, додай assertNever – і подивись, скільки місць підсвітить компілятор. Кожне з них – точка, де новий статус зламав би UI мовчки. Потім додай вигаданий статус і засікти, за скільки секунд tsc знайде всі місця, які треба оновити. Порівняй із тим, скільки це зайняло б через баг-репорти.