Запропонувати правкуПокращити цю статтюДопрацюйте відповідь до «Умовні типи в TypeScript». Ваші зміни проходять модерацію перед публікацією.Потрібне підтвердженняКонтентЩо ви змінюєте🇺🇸EN🇺🇦UAПереглядЗаголовок (UA)Коротка відповідь (UA)**Умовні типи (conditional types)** в TypeScript обчислюють `T extends U ? X : Y` під час компіляції, обираючи один з двох типів за принципом сумісності. Коли T - голий generic, умова розподіляється по кожному члену union окремо. ```typescript type Unwrap<T> = T extends Promise<infer U> ? U : T; type R = Unwrap<Promise<string>>; // string ``` **Ключове:** розподіл відбувається тільки з голим generic-параметром, не з загорнутим T.Показується над повною відповіддю для швидкого нагадування.Відповідь (UA)Зображення**Умовні типи (conditional types)** в TypeScript обирають один з двох типів залежно від того, чи сумісний `T` з `U`, за синтаксисом `T extends U ? X : Y`, який виконується повністю на етапі компіляції. ## Теорія ### TL;DR - Синтаксис: `T extends U ? X : Y`. T підходить до U - отримуєш X. Ні - отримуєш Y. - Як вендингова машина: вставляєш тип, перевіряєш чи підходить, отримуєш один результат. - Головна відмінність від union: умовні типи розподіляються по кожному члену union окремо, коли T - голий generic-параметр. - `infer` захоплює підтип прямо під час перевірки: `T extends Promise<infer U> ? U : T`. - На цьому побудовані: `ReturnType`, `Exclude`, `Extract`, `NonNullable`, `Parameters`. ### Швидкий приклад ```typescript type IsString<T> = T extends string ? "yes" : "no"; type A = IsString<string>; // "yes" type B = IsString<number>; // "no" type C = IsString<"hi">; // "yes" -- літерал "hi" є підтипом string ``` `T extends string` перевіряє структурну сумісність, а не ідентичність. Рядкові літерали проходять, бо є підтипами `string`. ### Головна відмінність від union Union `string | number` просто перераховує варіанти. Умовний тип виконує перевірку і повертає один конкретний результат. Саме тому `ReturnType<T>` працює, а жоден union це не замінить: потрібно перевірити `T extends (...args: any[]) => infer R`, щоб захопити R. У union такого механізму немає. ### Коли використовувати - Витягнути тип повернення або параметрів функції -> використай `infer`. - Відфільтрувати члени union, видаливши збіги -> `T extends SomeType ? never : T` (це і є `Exclude`). - Застосувати трансформацію тільки до об'єктних типів -> `T extends object ? DeepPartial<T> : T`. - Побудувати utility-тип що розгалужується по формі, замість того щоб писати 3-4 перевантаження вручну. Якщо звичайний union або intersection справляється, умовний тип не потрібен. Не кожна задача типізації вимагає тернарного оператора. ### Розподіл по union-типах Коли передаєш union у умовний тип де T - голий generic-параметр, TypeScript розбиває union і запускає умову для кожного члена окремо: ```typescript type Wrap<T> = T extends any ? [T] : never; type A = Wrap<string | number>; // Виконується як: Wrap<string> | Wrap<number> // Результат: [string] | [number] ``` Якщо T загорнутий у дужки, масив або інший generic, розподіл зупиняється: ```typescript type WrapFixed<T> = [T] extends [any] ? T[] : never; type B = WrapFixed<string | number>; // Результат: (string | number)[] -- обробляється як один тип ``` Загортання в `[T]` - стандартний спосіб вимкнути розподіл (distribution). ### Ключове слово `infer` `infer` оголошує змінну типу всередині умовної гілки і дозволяє TypeScript захопити тип який туди підходить: ```typescript type UnwrapPromise<T> = T extends Promise<infer U> ? U : T; type A = UnwrapPromise<Promise<string>>; // string type B = UnwrapPromise<number>; // number -- повертається без змін ``` `infer` можна використовувати кілька разів щоб витягнути голову кортежу, параметри функції чи вкладені generics: ```typescript type Head<T> = T extends [infer First, ...infer Rest] ? First : never; type H = Head<[string, number, boolean]>; // string ``` ### Як компілятор вирішує умовні типи TypeScript перевіряє сумісність структурно за один прохід. Для розподільчих умовних типів компілятор запускає умову по одному разу для кожного члена union коли T знаходиться в голій generic-позиції. Жодних витрат у runtime - весь механізм стирається при компіляції в JavaScript. Один нюанс: якщо T ще не вирішений у тілі generic-функції, компілятор залишає умовний тип необчисленим до тих пір поки не отримає конкретний тип на місці виклику. ### Типові помилки **Очікування розподілу коли T не голий** ```typescript // Розподіляється -- T тут голий type Id<T> = T extends any ? T : never; type X = Id<string | number>; // string | number // НЕ розподіляється -- T загорнутий в Array<> type Wrapped<T> = Array<T> extends any ? T : never; type Y = Wrapped<string | number>; // string | number -- без розбивки ``` Розподіл відбувається тільки коли перевіряється сам голий generic T, а не T загорнутий у щось. **Використання `any` в умові розширює літерали** ```typescript // Погано -- "a" розширюється до string type ToArray<T> = T extends any ? T[] : never; type Leak = ToArray<"a" | "b">; // string[] // Добре -- літерали зберігаються type ToArraySafe<T> = T extends unknown ? T[] : never; type Fixed = ToArraySafe<"a" | "b">; // "a"[] | "b"[] ``` `unknown` зберігає літеральні типи. `any` запускає fresh inference і часто їх розширює. **Очікування що `never extends U` буде false** ```typescript type A = never extends string ? true : false; // true ``` `never` - це підтип будь-якого типу, тому він проходить будь-яку перевірку `extends`. Члени union що стають `never` автоматично зникають з результату. Зазвичай це і є потрібна поведінка у фільтрах типу `Exclude`, але перший раз це дивує. ### Де це зустрічається у реальних проектах - React Query: `UseQueryResult<TData>` використовує умовний infer для розгортання async-типів даних. - Zod: `z.infer<typeof schema>` вирішується через `T extends z.ZodType ? T['_output'] : never`. - tRPC: типи процедур розгалужуються по `TRPCError` щоб побудувати типізований `Result<Success, Failure>`. - Стандартна бібліотека TypeScript: `Exclude`, `Extract`, `NonNullable`, `ReturnType`, `Parameters` - всі є умовними типами під капотом. ### Follow-up питання **Q:** Напиши умовний тип що витягує всі типи параметрів функції. **A:** `type Params<T> = T extends (...args: infer P) => any ? P : never;` Тут P захоплює повний кортеж параметрів, тому `Params<(a: string, b: number) => void>` дає `[string, number]`. **Q:** Яка різниця між `T extends U ? X : Y` і `[T] extends [U] ? X : Y`? **A:** Версія з дужками вимикає розподіл. Без дужок union T розбивається і умова виконується для кожного члена. З дужками T обробляється як один тип незалежно від того, чи він union. **Q:** Чому `never extends string` дає `true`? **A:** `never` - це bottom type, підтип будь-чого. Тому він проходить будь-яку перевірку `extends`. При фільтрації union члени що стають `never` зникають з результату - саме на цьому тримається поведінка `Exclude` і `NonNullable`. **Q:** Як умовні типи взаємодіють з mapped types? **A:** Їх можна комбінувати для фільтрації ключів об'єкта. `type Filter<T, U> = { [K in keyof T]: T[K] extends U ? T[K] : never }` залишає тільки значення сумісні з U. Додай `[keyof T]` в кінці щоб скласти об'єкт в union значень. **Q:** (Senior) Чому і `T extends any`, і `T extends unknown` розподіляються, а `[T] extends [any]` - ні? **A:** Розподіл запускається позицією голого T в умові, а не тим, з чим T порівнюється. Загортання T в `[T]` змінює його позицію з голого generic на член кортежу. Ця одна структурна зміна повністю зупиняє механізм розподілу. ## Приклади ### Витягування типу повернення через `infer` Патерн на якому побудований вбудований `ReturnType<T>` в TypeScript: ```typescript type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never; async function fetchUser(): Promise<{ id: number; name: string }> { return { id: 1, name: "Alice" }; } type FetchResult = MyReturnType<typeof fetchUser>; // Promise<{ id: number; name: string }> // В комбінації з Awaited щоб розгорнути Promise: type UserData = Awaited<FetchResult>; // { id: number; name: string } ``` `infer R` захоплює все що повертає функція. Якщо T не є функцією - результат `never`. ### Фільтрація union через розподіл ```typescript type OnlyStrings<T> = T extends string ? T : never; type Mixed = "admin" | "user" | 42 | true; type StringRoles = OnlyStrings<Mixed>; // "admin" | "user" ``` TypeScript виконує `OnlyStrings` для кожного члена union окремо. Number і boolean стають `never` і зникають з фінального union. Саме так `Extract<T, string>` працює всередині. ### Рекурсивний DeepPartial ```typescript type DeepPartial<T> = T extends object ? { [K in keyof T]?: DeepPartial<T[K]> } : T; interface Config { server: { host: string; port: number; }; debug: boolean; } type PartialConfig = DeepPartial<Config>; // { // server?: { host?: string; port?: number }; // debug?: boolean; // } ``` Умова `T extends object` розгалужує об'єкти і примітиви. Примітиви повертаються без змін, об'єкти рекурсивно загортаються. Я використовував цей патерн при побудові API-клієнтів де patch-ендпоінти приймали оновлення на будь-якій глибині без необхідності писати окремий partial-тип для кожного вкладеного інтерфейсу.Для рев’юераПримітка для модератора (необов’язково)Бачить лише модератор. Прискорює рев’ю.