Дискриміновані об'єднання в TypeScript
Discriminated union - це union-тип, де кожен член має спільну літеральну властивість (дискримінант), яка дозволяє TypeScript автоматично звузити тип до конкретного члена під час перевірок control flow у switch та if.
Теорія
TL;DR
- Як коробки з написами «КРИХКЕ», «ОДЯГ» або «КНИГИ»: напис каже як поводитись із вмістом, не відкриваючи
- Головна відмінність від звичайних union-типів: TypeScript сам звужує (narrows) тип за значенням дискримінанта, без ручних type guard
- Дискримінант - обов'язкова літеральна властивість на кожному члені union, однакове ім'я скрізь
- Підходить коли 2+ пов'язаних типи мають спільну структуру, але різні поля
- Додай
default: const _: never = xуswitchдля перевірки вичерпності на рівні компілятора
Швидкий приклад
type Event =
| { type: 'click'; x: number; y: number }
| { type: 'keydown'; key: string };
function handleEvent(event: Event) {
if (event.type === 'click') {
// TypeScript звузив тут: x та y доступні без помилок
console.log(`Клік на ${event.x}, ${event.y}`);
} else {
// TypeScript звузив тут: key доступний без помилок
console.log(`Натиснута клавіша: ${event.key}`);
}
}type тут - дискримінант. У кожній гілці TypeScript точно знає з яким членом ти працюєш. Жодних приведень типів, жодного as Event, жодних runtime помилок від звернення до неіснуючих властивостей.
Головна відмінність від звичайних union-типів
Зі звичайним union на кшталт string | number потрібен typeof при кожному зверненні. З discriminated union аналіз control flow зчитує літеральне значення дискримінанта і автоматично звужує тип до одного члена. Різниця найвідчутніша при доступі до специфічних полів: без дискримінанта TypeScript видає помилку до кожної перевірки, а з discriminated union достатньо перевірити дискримінант один раз - і все нижче вже звужено.
Коли використовувати
- Кілька форм об'єктів зі спільною структурою, але різними полями -> discriminated union
- API-відповіді що змінюють форму залежно від статусу ->
{ status: 'success'; data: T } | { status: 'error'; error: Error } - Обробка подій де тип події визначає наявні властивості -> discriminated union
- Стан-машини де кожен стан містить різні дані -> discriminated union
- Непов'язані примітиви на кшталт
string | number-> звичайний union, дискримінант не потрібен - Один тип з кількома опціональними полями -> звичайний інтерфейс
Як компілятор це обробляє
Аналіз control flow TypeScript відстежує значення дискримінанта по гілках. Коли бачить shape.kind === 'circle', присвоює тип Circle змінній shape для всього всередині цієї гілки. Жодних накладних витрат в runtime: типи стираються до того як V8 виконує код. З TypeScript 4.4 switch на літеральному дискримінанті також сигналізує про необроблені випадки при використанні never у default.
Типові помилки
Помилка 1: string замість літерального типу
// Неправильно: kind є string, звуження (narrowing) неможливе
type Dog = { kind: string; barks: boolean };
// Правильно: kind є літерал
type Dog = { kind: 'dog'; barks: boolean };TypeScript бачить kind: string і не може звузити тип. Доведеться писати as Dog скрізь - і весь сенс discriminated union зникає.
Помилка 2: Різні назви дискримінанта в різних членах
// Неправильно
type Circle = { kind: 'circle'; radius: number };
type Square = { type: 'square'; size: number }; // 'type' замість 'kind'
type Shape = Circle | Square;
shape.kind; // Property 'kind' does not exist on type 'Square'Усі члени повинні використовувати одне й те саме ім'я властивості. Вибери одне і тримайся його.
Помилка 3: Опціональний дискримінант
// Неправильно
type Action = { type?: 'LOAD' }; // опціональний
// Правильно
type Action = { type: 'LOAD' }; // обов'язковийЯкщо дискримінант опціональний, TypeScript не може гарантувати наявність значення під час звуження. Весь механізм перестає працювати.
Помилка 4: Відсутність перевірки вичерпності
function area(shape: Shape) {
switch (shape.kind) {
case 'circle': return Math.PI * shape.radius ** 2;
// Додали 'triangle' пізніше? Тихий fallthrough до undefined.
}
}
// Виправлення: додай до default
default:
const _exhaustive: never = shape; // помилка компілятора якщо case пропущено
return _exhaustive;Без never guard новий член union доданий через кілька тижнів тихо пропаде до undefined. Присвоєння never перетворює це на помилку компіляції одразу.
Помилка 5: Union літерал всередині одного члена
type A = { type: 'a' | 'b' }; // union всередині одного членаif (x.type === 'a') не звузить тип до окремого самостійного члена. Кожен член повинен мати рівно один літерал.
Де зустрічається в реальних проектах
- React Query:
{ status: 'loading' } | { status: 'success'; data: T } | { status: 'error'; error: Error }- внутрішня форма результатуuseQuery - Redux Toolkit:
{ type: 'counter/increment' } | { type: 'counter/decrement' }- кожна дія є членом discriminated union - Zod:
{ success: true; data: T } | { success: false; error: ZodError }- результат парсингу використовуєsuccessяк дискримінант - tRPC: результати процедур дискримінуються по внутрішньому полю
_tag - Express middleware: типізовані варіанти запиту на кшталт
{ auth: 'user' } | { auth: 'admin' }для рольового доступу на рівні типів
Питання на співбесіді
Q: Яка різниця між discriminated union і поліморфною ієрархією класів?
A: Класи використовують номінальну типізацію та перевірки через instanceof. Discriminated union працюють зі звичайними об'єктами та структурною типізацією. Union краще підходять для функціональних патернів, класи - для OOP.
Q: Як забезпечити вичерпність (exhaustiveness) у switch?
A: Додай гілку default, яка присвоює значення типу never: const _: never = action. Якщо якийсь випадок не оброблений, TypeScript видасть помилку, бо необроблений член не можна присвоїти never.
Q: Чи може дискримінант бути вкладеною властивістю об'єкта?
A: Так. { kind: { shape: 'circle' } } працює якщо вкладений літерал збігається точно. На практиці плоскі дискримінанти набагато зручніші в роботі.
Q: Чи є runtime накладні витрати?
A: Жодних. TypeScript типи стираються. switch виконується як звичайний JavaScript на рівні V8.
Q: Як витягнути конкретний тип члена за значенням дискримінанта?
A: Через вбудований Extract: type ClickEvent = Extract<UIEvent, { type: 'click' }>. Отримаєш саме { type: 'click'; x: number; y: number }.
Q: Чи працює з generics?
A: Так, і це один з найпоширеніших патернів: type Result<T> = { ok: true; value: T } | { ok: false; error: string }. Дискримінант ok працює однаково незалежно від того, чим є T.
Приклади
Базовий: обчислення площі фігур
type Circle = { kind: 'circle'; radius: number };
type Square = { kind: 'square'; size: number };
type Rectangle = { kind: 'rectangle'; width: number; height: number };
type Shape = Circle | Square | Rectangle;
function getArea(shape: Shape): number {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2; // radius доступний тут
case 'square':
return shape.size ** 2; // size доступний тут
case 'rectangle':
return shape.width * shape.height; // width та height доступні тут
default:
const _exhaustive: never = shape; // помилка компілятора якщо case пропущено
return _exhaustive;
}
}
console.log(getArea({ kind: 'circle', radius: 5 })); // 78.54Три фігури, один дискримінант kind. TypeScript звужує тип до одного члена в кожному case. never у default означає: додай Triangle без обробки - отримаєш помилку компілятора одразу тут.
Середній: стан асинхронного запиту в стилі React Query
type AsyncState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error };
function renderUserCard(state: AsyncState<{ name: string }>) {
switch (state.status) {
case 'idle':
return 'Не розпочато';
case 'loading':
return 'Завантаження...';
case 'success':
return `Привіт, ${state.data.name}`; // data є тільки у 'success'
case 'error':
return `Помилка: ${state.error.message}`; // error є тільки у 'error'
}
}Цей патерн зустрічається в майже кожному хуку для запитів. Без дискримінанта довелося б мати data?: T та error?: Error на одному типі, що дозволяє читати state.data коли запит завершився помилкою. Discriminated union робить це помилкою компіляції.
Просунутий: вкладені union з узагальненим типом Result
type NetworkError = { kind: 'network'; statusCode: number };
type ValidationError = { kind: 'validation'; fields: Record<string, string> };
type AuthError = { kind: 'auth'; reason: 'expired' | 'invalid' };
type AppError = NetworkError | ValidationError | AuthError;
type Result<T> =
| { ok: true; value: T }
| { ok: false; error: AppError };
function handleResult<T>(result: Result<T>): string {
if (result.ok) {
return `OK: ${JSON.stringify(result.value)}`;
}
// Звужено до { ok: false; error: AppError }
// Тепер звужуємо ще раз по вкладеному union
switch (result.error.kind) {
case 'network':
return `Помилка мережі: ${result.error.statusCode}`;
case 'validation':
return `Валідація: ${Object.keys(result.error.fields).join(', ')}`;
case 'auth':
return `Авторизація: ${result.error.reason}`;
default:
const _: never = result.error;
return _;
}
}Два рівні звуження: спочатку по ok, потім по error.kind. Це патерн Result<T> з бібліотек на кшталт fp-ts. Я використовував його в production API де один endpoint міг повертати три різних типи помилок - і вкладений union гарантував що поля кожного типу не змішаєш із полями іншого.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.