Запропонувати правкуПокращити цю статтюДопрацюйте відповідь до «Дискриміновані об'єднання в TypeScript». Ваші зміни проходять модерацію перед публікацією.Потрібне підтвердженняКонтентЩо ви змінюєте🇺🇸EN🇺🇦UAПереглядЗаголовок (UA)Коротка відповідь (UA)**Discriminated union** - union-тип, де кожен член має спільну літеральну властивість (дискримінант), яка дозволяє TypeScript автоматично звузити тип до конкретного члена. ```typescript type Result = | { status: 'success'; data: string } | { status: 'error'; error: Error }; function handle(r: Result) { if (r.status === 'success') console.log(r.data); // звужено } ``` **Головне:** дискримінант - обов'язкова літеральна властивість на кожному члені union.Показується над повною відповіддю для швидкого нагадування.Відповідь (UA)Зображення**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 гарантував що поля кожного типу не змішаєш із полями іншого.Для рев’юераПримітка для модератора (необов’язково)Бачить лише модератор. Прискорює рев’ю.