Умовні типи в 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.
Швидкий приклад
type IsString<T> = T extends string ? "yes" : "no";
type A = IsString<string>; // "yes"
type B = IsString<number>; // "no"
type C = IsString<"hi">; // "yes" -- літерал "hi" є підтипом stringT 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 і запускає умову для кожного члена окремо:
type Wrap<T> = T extends any ? [T] : never;
type A = Wrap<string | number>;
// Виконується як: Wrap<string> | Wrap<number>
// Результат: [string] | [number]Якщо T загорнутий у дужки, масив або інший generic, розподіл зупиняється:
type WrapFixed<T> = [T] extends [any] ? T[] : never;
type B = WrapFixed<string | number>;
// Результат: (string | number)[] -- обробляється як один типЗагортання в [T] - стандартний спосіб вимкнути розподіл (distribution).
Ключове слово infer
infer оголошує змінну типу всередині умовної гілки і дозволяє TypeScript захопити тип який туди підходить:
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
type A = UnwrapPromise<Promise<string>>; // string
type B = UnwrapPromise<number>; // number -- повертається без змінinfer можна використовувати кілька разів щоб витягнути голову кортежу, параметри функції чи вкладені generics:
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 не голий
// Розподіляється -- 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 в умові розширює літерали
// Погано -- "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
type A = never extends string ? true : false; // truenever - це підтип будь-якого типу, тому він проходить будь-яку перевірку 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:
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 через розподіл
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
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-тип для кожного вкладеного інтерфейсу.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.