Skip to main content

Дискриміновані об'єднання в TypeScript

Discriminated union - це union-тип, де кожен член має спільну літеральну властивість (дискримінант), яка дозволяє TypeScript автоматично звузити тип до конкретного члена під час перевірок control flow у switch та if.

Теорія

TL;DR

  • Як коробки з написами «КРИХКЕ», «ОДЯГ» або «КНИГИ»: напис каже як поводитись із вмістом, не відкриваючи
  • Головна відмінність від звичайних union-типів: TypeScript сам звужує (narrows) тип за значенням дискримінанта, без ручних type guard
  • Дискримінант - обов'язкова літеральна властивість на кожному члені union, однакове ім'я скрізь
  • Підходить коли 2+ пов'язаних типи мають спільну структуру, але різні поля
  • Додай default: const _: never = x у switch для перевірки вичерпності на рівні компілятора

Швидкий приклад

typescript
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 замість літерального типу

typescript
// Неправильно: kind є string, звуження (narrowing) неможливе type Dog = { kind: string; barks: boolean }; // Правильно: kind є літерал type Dog = { kind: 'dog'; barks: boolean };

TypeScript бачить kind: string і не може звузити тип. Доведеться писати as Dog скрізь - і весь сенс discriminated union зникає.

Помилка 2: Різні назви дискримінанта в різних членах

typescript
// Неправильно 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: Опціональний дискримінант

typescript
// Неправильно type Action = { type?: 'LOAD' }; // опціональний // Правильно type Action = { type: 'LOAD' }; // обов'язковий

Якщо дискримінант опціональний, TypeScript не може гарантувати наявність значення під час звуження. Весь механізм перестає працювати.

Помилка 4: Відсутність перевірки вичерпності

typescript
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 літерал всередині одного члена

typescript
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.

Приклади

Базовий: обчислення площі фігур

typescript
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

typescript
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

typescript
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 гарантував що поля кожного типу не змішаєш із полями іншого.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?