Skip to main content

Умовні типи в TypeScript

Умовні типи (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-тип для кожного вкладеного інтерфейсу.

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

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

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

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