$ sudo teach IT
МОДУЛЬ 7 · УРОК 5

Условные типы (Conditional Types)

Научимся создавать типы, которые принимают решения на основе условий — как if/else, но на уровне типов. Это один из самых мощных инструментов TypeScript для построения гибких и безопасных абстракций.

⏱ ~30 мин
📐 Продвинутый
🧠 infer
🔧 Utility Types
🎯 Distributive Types

Зачем нужны условные типы?

Представьте, что вы работаете в банке. Клиент приходит и говорит: «Я хочу взять кредит». Ваш вопрос: «Вы работаете или вы на пенсии?» — и в зависимости от ответа, вы выдаёте ему разные документы для заполнения. Если работаете — справку с работы. Если на пенсии — справку из пенсионного фонда.

Условные типы в TypeScript работают точно так же. Они позволяют типу «задавать вопрос»: «Этот тип расширяет другой?» — и в зависимости от ответа возвращать один или другой тип. Это как if/else, но не для значений, а для самих типов.

До появления условных типов TypeScript-разработчики были ограничены в том, как типы могут зависеть друг от друга. Приходилось использовать перегрузки функций, писать повторяющийся код или жертвовать типобезопасностью. Условные типы решили эту проблему, дав возможность создавать типы, которые динамически вычисляются на этапе компиляции.

В этом уроке мы разберём условные типы от самых основ до продвинутых паттернов. Вы увидите, как TypeScript использует их внутри себя (все знакомые utility types — это условные типы!), и научитесь строить собственные. Это один из самых важных уроков в нашем курсе — именно условные типы превращают TypeScript из простой системы аннотаций в полноценный язык программирования на уровне типов.

1. Синтаксис условных типов

Синтаксис условного типа выглядит знакомо любому, кто программировал на JavaScript:

type ТипРезультат = T extends U ? X : Y;

Давайте разберём каждую часть:

T
— тип, который мы проверяем. Это «клиент в банке», о котором мы задаём вопрос.
extends U
— условие проверки. «Расширяет ли T тип U?» Аналогия: «Вы работаете?»
X
— тип-результат, если условие истинно (да, работает). Аналогия: «Вот форма для работающих».
Y
— тип-результат, если условие ложно (нет, на пенсии). Аналогия: «Вот форма для пенсионеров».

Ключевое слово extends здесь имеет широкий смысл. В мире типов TypeScript «extends» означает «является подмножеством» или «compatible с». Когда мы пишем string extends "hello" | "world", это читается как: «Тип string является подмножеством union "hello" | "world"?»

Важно понимать: условные типы вычисляются на этапе компиляции, а не во время выполнения. TypeScript анализирует типы и заменяет условный тип на конкретный результат. Это называется редукцией (reduction) — TypeScript сворачивает условный тип до итогового типа, как калькулятор вычисляет выражение.

Условные типы можно читать как предложения на естественном языке: «Если T расширяет U, то верни X, иначе верни Y». Попробуйте проговорить это вслух — такая привычка поможет вам быстрее разбирать сложные типы.

2. Базовые условные типы — простые примеры

Начнём с самого простого примера. Создадим тип, который проверяет, является ли входной тип строкой:

type ЯвляетсяСтрокой<T> = T extends string ? "да" : "нет";

// Использование:
type Результат1 = ЯвляетсяСтрокой<string>;  // "да"
type Результат2 = ЯвляетсяСтрокой<number>;  // "нет"
type Результат3 = ЯвляетсяСтрокой<"привет">; // "да"

Здесь происходит следующее:

Анализ по шагам:

• ЯвляетсяСтрокой<string>: TypeScript проверяет string extends string — это истина (строка расширяет строку). Результат: "да".

• ЯвляетсяСтрокой<number>: TypeScript проверяет number extends string — это ложь (число не является строкой). Результат: "нет".

• ЯвляетсяСтрокой<"привет">: Строковый литерал "привет" расширяет string (все строковые литералы являются подмножеством string). Результат: "да".

Заметьте важную деталь: "привет" расширяет string, потому что строковый литерал — это более конкретный тип, который является подмножеством обобщённого string. Это как говорить: «Яблоко — это фрукт». «Яблоко» расширяет «фрукт».

Теперь сделаем пример посложнее — условный тип, который возвращает разные типы данных:

type ЗначениеИлиМассив<T> = T extends any[] ? T[number] : T;

type A = ЗначениеИлиМассив<string[]>;  // string (извлекаем элемент массива)
type B = ЗначениеИлиМассив<number[]>;  // number
type C = ЗначениеИлиМассив<boolean>;   // boolean (не массив, возвращаем как есть)

Этот тип решает практическую задачу: если передан массив — вернуть тип его элементов, если нет — вернуть сам тип. Здесь мы используем T[number] — индексный доступ к типу массива, который мы изучали в предыдущих уроках.

Ещё один полезный пример — тип, который добавляет null или убирает его:

type БезNull<T> = T extends null ? never : T;

type D = БезNull<string>;      // string
type E = БезNull<null>;        // never
type F = БезNull<string | null>; // string | never = string

Обратите внимание на последний случай: БезNull<string | null>. Здесь TypeScript автоматически распределяет условный тип по each элементам union — это называется распределительными условными типами (distributive conditional types). Мы подробно разберём это позже в уроке.

💡 Совет: Начинайте с простых условных типов и постепенно усложняйте. Первый шаг — научиться писать T extends U ? X : Y на автомате, прежде чем переходить к вложенным условиям и infer.

3. Условные типы с примитивными типами

Примитивные типы TypeScript (string, number, boolean, null, undefined, bigint, symbol, void, never) образуют чёткую иерархию. Условные типы позволяют нам этой иерархией пользоваться для создания умных типов-помощников.

Давайте создадим набор типов-предикатов для проверки примитивных типов. Это пригодится нам при создании более сложных условных типов:

type ЯвляетсяСтрокой<T> = T extends string ? true : false;
type ЯвляетсяЧислом<T> = T extends number ? true : false;
type ЯвляетсяБулевым<T> = T extends boolean ? true : false;
type ЯвляетсяМассивом<T> = T extends any[] ? true : false;

// Тесты:
type Т1 = ЯвляетсяСтрокой<"мир">;       // true
type Т2 = ЯвляетсяЧислом<42>;           // true
type Т3 = ЯвляетсяБулевым<true>;        // true
type Т4 = ЯвляетсяМассивом<number[]>;   // true
type Т5 = ЯвляетсяМассивом<string>;     // false

Видите паттерн? Мы строим «диагностические» типы, которые как анализатор крови определяют «группу» переданного типа. Эти маленькие кирпичики потом складываются в большие конструкции.

Теперь рассмотрим более практический пример — функция-конвертер, которая по типу входных данных определяет тип выходных:

type РезультатПреобразования<T> =
  T extends string ? number :
  T extends number ? string :
  T extends boolean ? "да" | "нет" :
  never;

// Теперь функция, которая использует этот тип:
function преобразовать<T extends string | number | boolean>(value: T): РезультатПреобразования<T> {
  if (typeof value === "string") {
    return Number(value) as any;
  } else if (typeof value === "number") {
    return String(value) as any;
  } else {
    return (value ? "да" : "нет") as any;
  }
}

const a = преобразовать("42");      // тип: number (а не string!)
const b = преобразовать(42);        // тип: string
const c = преобразовать(true);      // тип: "да" | "нет"
const d = преобразовать("hello");   // тип: number

Вот это уже впечатляет! Функция преобразовать принимает разные типы данных, но TypeScript точно знает, какой тип вернёт функция для каждого конкретного аргумента. Если передали строку — вернёт число. Если число — строку. Если булево значение — строку "да" или "нет".

Здесь мы использовали каскадный условный тип — цепочку из нескольких условий. Это аналог if / else if / else в обычном коде. TypeScript проверяет условия сверху вниз и останавливается на первом совпадении.

Заметьте использование as any в теле функции — это компромисс. Мы знаем, что логика правильная, но TypeScript не может проследить эту связь автоматически ввыполнениеtime. Условные типы работают на уровне типов, а не значений. В реальном проекте такой паттерн используют с осторожностью.

Рассмотрим ещё один важный аспект — как условные типы работают с literal types:

type ОписаниеТипа<T> =
  T extends string ? "текстовый" :
  T extends number ? "числовой" :
  T extends boolean ? "логический" :
  T extends null ? "пустой" :
  T extends undefined ? "неопределённый" :
  T extends symbol ? "символьный" :
  "неизвестный";

// Литералы расширяют свои базовые типы:
type П1 = ОписаниеТипа<"hello">;    // "текстовый" (string literal → string)
type П2 = ОписаниеТипа<42>;         // "числовой"
type П3 = ОписаниеТипа<true>;       // "логический"
type П4 = ОписаниеТипа<null>;       // "пустой"
type П5 = ОписаниеТипа<undefined>;  // "неопределённый"
type П6 = ОписаниеТипа<{}>;         // "неизвестный"
type П7 = ОписаниеТипа<any>;        // "текстовый" | "числовой" | "логический" | ...

Последний случай особенно интересен: any является одновременно подмножеством ВСЕХ типов и supertype всех типов. Поэтому any extends string — истина, но также и any extends number, и any extends boolean. В результате TypeScript возвращает union всех возможных результатов.

⚠️ Важно: Запомните, что any и unknown ведут себя по-разному в условных типах. any extends T всегда истинно (any «маскируется» под любой тип). unknown extends T — истина только когда T — это unknown или any. Unknown более строгий и безопасный.

4. Ключевое слово infer — извлечение типов

Ключевое слово infer — это, пожалуй, самая мощная и самая сложная часть условных типов. Оно позволяет «захватить» (infer — «вывести») внутренний тип из более сложной структуры, пока проверяется условие.

Аналогия из жизни: Представьте, что вы полицейский и проверяете машину. Вы говорите: «Покажите мне, что у вас в бардачке». В момент проверки вы не просто устанавливаете факт («в бардачке что-то есть»), но ещё и извлекаете конкретное содержимое. infer делает то же самое с типами — он не просто проверяет соответствие, но ещё и «достаёт» часть типа для дальнейшего использования.

Синтаксис:

type Пример<T> = T extends (infer U)[] ? U : never;

Здесь infer U означает: «Если T является массивом, то запомни тип его элементов как U и используй его». Если T не массив — возвращаем never (пустой тип).

type ЭлементМассива<T> = T extends (infer U)[] ? U : never;

type E1 = ЭлементМассива<string[]>;     // string
type E2 = ЭлементМассива<number[]>;     // number
type E3 = ЭлементМассива<boolean[]>;    // boolean
type E4 = ЭлементМассива<(string | number)[]>; // string | number
type E5 = ЭлементМассива<string>;       // never (не массив!)

Без infer мы не смогли бы «достать» тип элемента. Мы могли бы проверить, что T — это массив, но не смогли бы определить тип его элементов. Infer — это то, что превращает условные типы из простого «переключателя» в инструмент для анализа и декомпозиции типов.

Давайте рассмотрим более сложный пример — извлечение типа промиса:

type РезультатПромиса<T> = T extends Promise<infer U> ? U : T;

type P1 = РезультатПромиса<Promise<string>>;    // string
type P2 = РезультатПромиса<Promise<number>>;    // number
type P3 = РезультатПромиса<Promise<boolean[]>>; // boolean[]
type P4 = РезультатПромиса<string>;               // string (не промис)

Здесь Promise<infer U> «смотрит внутрь» промиса и захватывает тип его значения. Если передан не промис — просто возвращаем исходный тип T. Это безопасно: тип промиса извлекается, а если промиса нет — ничего не ломается.

Ещё один важный момент: infer работает только в позиции, где TypeScript может однозначно определить тип. Вы не можете использовать infer в «свободной позиции» — он должен быть привязан к проверяемому типу через шаблонный паттерн:

⚠️ Ошибка: type Неправильно<T> = T extends infer U ? U : never; — здесь infer бесполезен, потому что U всегда будет равен T. Infer имеет смысл только когда он извлекает часть структуры, а не весь тип целиком.

Ключевой принцип: infer создаёт новую переменную типа, которая заполняется только если условие выполняется. Если условие ложно — переменная infer не определена, и вы не можете её использовать (поэтому нужна ветка : never или другой fallback).

5. infer в типах возвращаемых значений функций

Одно из самых частых применений infer — извлечение типа возвращаемого значения функции. Это настолько распространённая задача, что TypeScript уже имеет встроенную utility type ReturnType<T>. Но давайте сначала посмотрим, как она работает «под капотом»:

// Вот как определён ReturnType в исходниках TypeScript (упрощённо):
type ReturnType<T extends (...args: any) => any> =
  T extends (...args: any) => infer R ? R : any;

// Создадим функции:
function получитьПользователя(): { id: number; name: string } {
  return { id: 1, name: "Анна" };
}

function получитьСтроку(): string {
  return "привет";
}

function получитьЧисло(): number {
  return 42;
}

// Извлекаем типы возвращаемых значений:
type ТипПользователя = ReturnType<typeof получитьПользователя>;
// { id: number; name: string }

type ТипСтроки = ReturnType<typeof получитьСтроку>;   // string
type ТипЧисла = ReturnType<typeof получитьЧисло>;     // number

Обратите внимание на шаблон (...args: any) => infer R. Это шаблон для функции с любыми аргументами. Infer R захватывает тип возвращаемого значения. Если T — это функция, R получит её return type. Если T не функция — возвращается any.

Это невероятно полезно при работе с колбэками и функциями обратного вызова:

// Функция, которая принимает функцию и возвращает обёртку с логированием
function сЛогированием<Ф extends (...args: any[]) => any>(
  fn: Ф
): (...args: Parameters<Ф>) => ReturnType<Ф> {
  return (...args) => {
    console.log(`Вызов функции с аргументами:`, args);
    const результат = fn(...args);
    console.log(`Результат:`, результат);
    return результат;
  };
}

// Использование:
function сложить(a: number, b: number): number {
  return a + b;
}

const сложитьСЛогом = сЛогированием(сложить);

// TypeScript ЗНАЕТ:
// - аргументы: (a: number, b: number)
// - возвращаемое значение: number
sloditSlogom(2, 3); // OK
// сложитьСЛогом("а", "б"); // Ошибка! TypeScript проверяет типы аргументов!

Потрясающе, правда? Функция сЛогированием является полностью универсальной — она работает с любой функцией и автоматически сохраняет её типы аргументов и возвращаемого значения. Без условных типов и infer это было бы невозможно (пришлось бы писать перегрузки для каждого типа функции).

Давайте сделаем ещё один полезный пример — тип, который извлекает тип обещанного значения, рекурсивно раскрывая Promise:

// Рекурсивно «разворачиваем» вложенные Promise
type ГлубокийРезультат<T> =
  T extends Promise<infer U> ? ГлубокийРезультат<U> : T;

type R1 = ГлубокийРезультат<Promise<string>>;               // string
type R2 = ГлубокийРезультат<Promise<Promise<number>>>;     // number
type R3 = ГлубокийРезультат<Promise<Promise<Promise<boolean>>>>; // boolean
type R4 = ГлубокийРезультат<string>;                          // string

Этот тип рекурсивно «распаковывает» все уровни Promise, пока не дойдёт до базового типа. Видите мощь? Условные типы могут быть рекурсивными — они могут вызывать сами себя! Это позволяет обрабатывать структуры произвольной глубины.

💡 Совет: Когда создаёте обёртки, декораторы или функции высшего порядка, используйте infer для сохранения типов оригинальных функций. Это ключ к написанию типобезопасных абстракций.

6. infer в типах параметров функций

Аналогично извлечению типа возвращаемого значения, мы можем извлекать типы параметров функции. Это делается с помощью встроенного Parameters<T>, который работает по тому же принципу:

// Вот как определён Parameters:
type Parameters<T extends (...args: any) => any> =
  T extends (...args: infer P) => any ? P : never;

// Примеры:
function создатьЗапись(имя: string, возраст: number, email: string): void {}

type ПараметрыЗаписи = Parameters<typeof создатьЗапись>;
// [имя: string, возраст: number, email: string]

// Теперь извлечём первый параметр:
type ПервыйПараметр<T extends (...args: any) => any> =
  T extends (первый: infer P, ...rest: any[]) => any ? P : never;

type П1 = ПервыйПараметр<typeof создатьЗапись>; // string

Здесь infer P стоит в позиции первого аргумента функции, а ...rest: any[] «съедает» все остальные. Это позволяет нам извлечь только первый параметр, игнорируя остальные.

Давайте создадим более сложный пример — тип, который извлекает тип колбэка по имени события:

// Тип конфигурации обработчиков событий
type ОбработчикиСобытий = {
  клик: (x: number, y: number) => void;
  наведение: (элемент: HTMLElement) => void;
  нажатие: (клавиша: string, модификаторы: boolean) => void;
};

// Извлекаем тип обработчика по имени события
type ТипОбработчика<События, Имя extends keyof События> =
  События[Имя] extends (...args: infer A) => any
    ? A
    : never;

type АргументыКлика = ТипОбработчика<ОбработчикиСобытий, "клик">;
// [x: number, y: number]

type АргументыНаведения = ТипОбработчика<ОбработчикиСобытий, "наведение">;
// [элемент: HTMLElement]

Мы извлекаем типы аргументов обработчиков событий из объекта-маппинга. Это позволяет нам создавать функцию-эмиттер, которая будет типобезопасно вызывать обработчики:

function создатьЭмиттер<События extends Record<string, (...args: any[]) => void>>(
  обработчики: События
) {
  return {
    вызвать<Имя extends keyof События>(
      событие: Имя,
      ...аргументы: ТипОбработчика<События, Имя>
    ): void {
      обработчики[событие](...аргументы);
    }
  };
}

const эмиттер = создатьЭмиттер({
  клик: (x, y) => console.log(`Клик на ${x}, ${y}`),
  наведение: (эл) => console.log("Наведение на", эл),
});

эмиттер.вызвать("клик", 10, 20);    // OK
// эмиттер.вызвать("клик", "а", 20); // Ошибка! x должен быть number

Теперь TypeScript автоматически знает, какие аргументы нужны для каждого события. Если вы напишете "клик", TypeScript предложит аргументы (x: number, y: number). Если напишете "наведение" — предложит (элемент: HTMLElement). Полная типобезопасность без повторения кода!

7. infer в типах кортежей (tuple types)

Кортежи (tuple types) — это массивы с фиксированной длиной и известными типами на каждой позиции. Infer в сочетании с кортежами позволяет нам работать с типами как со структурами, извлекая элементы по позиции:

// Извлекаем первый элемент кортежа
type ПервыйЭлемент<T extends any[]> = T extends [infer Первый, ...any[]] ? Первый : never;

type FE1 = ПервыйЭлемент<[string, number, boolean]>;     // string
type FE2 = ПервыйЭлемент<[42, "мир", true]>;              // 42
type FE3 = ПервыйЭлемент<[]>;                             // never

// Извлекаем последний элемент кортежа
type ПоследнийЭлемент<T extends any[]> =
  T extends [...any[], infer Последний] ? Последний : never;

type LE1 = ПоследнийЭлемент<[string, number, boolean]>;  // boolean
type LE2 = ПоследнийЭлемент<[42]>;                        // 42

// Извлекаем всё кроме первого элемента (rest)
type БезПервого<T extends any[]> = T extends [any, ...infer Остальное] ? Остальное : [];

type Rest1 = БезПервого<[string, number, boolean]>;      // [number, boolean]
type Rest2 = БезПервого<[string]>;                        // []

Видите паттерн? Мы используем spread-оператор (...) в типах, точно так же как в JavaScript. Это позволяет нам «разделять» кортеж на части: первый элемент, последний элемент, всё кроме первого, и т.д.

Давайте создадим рекурсивный тип, который разворачивает кортеж задом наперёд:

// Рекурсивный разворот кортежа
type Разворот<T extends any[]> =
  T extends [infer Первый, ...infer Остальные]
    ? [...Разворот<Остальные>, Первый]
    : [];

type Rev1 = Разворот<[1, 2, 3]>;        // [3, 2, 1]
type Rev2 = Разворот<["a", "b", "c"]>;  // ["c", "b", "a"]
type Rev3 = Разворот<[boolean]>;         // [boolean]
type Rev4 = Разворот<[]>;                // []

Здесь мы видим рекурсию в действии: на каждом шаге мы берём первый элемент и рекурсивно разворачиваем остаток. Когда кортеж пуст — возвращаем пустой массив. Это работает аналогично рекурсивным функциям в JavaScript, но на уровне типов!

Ещё один полезный паттерн — извлечение типов аргументов функции как кортежа и их маппинг:

// Делаем все параметры функции опциональными
type ВсеОпционально<T extends (...args: any[]) => any> =
  T extends (...args: infer A) => any
    ? (...args: { [K in keyof A]?: A[K] }) => ReturnType<T>
    : never;

function поиск(имя: string, возраст: number, активен: boolean): string {
  return "";
}

// Все параметры теперь опциональные:
type ПоискОпц = ВсеОпционально<typeof поиск>;
// (имя?: string, возраст?: number, активен?: boolean) => string

Мы извлекли типы параметров с помощью infer, а затем модифицировали их, сделав опциональными через mapped types. Это отличный пример того, как условные типы, infer и mapped types работают вместе.

8. Встроенные utility types, использующие условные типы

TypeScript имеет целый набор встроенных utility types, которые построены на условных типах. Теперь, когда вы понимаете условные типы, вы можете посмотреть «под капот» и увидеть, как эти инструменты работают.

Exclude<T, U> — исключение типов

Exclude убирает из union-типа T все типы, которые расширяют U. Вот как он определён:

// Определение Exclude (упрощённо):
type Exclude<T, U> = T extends U ? never : T;

// Пример:
type Методы = "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
type ТолькоЧтение = Exclude<Методы, "POST" | "PUT" | "DELETE" | "PATCH">;
// Результат: "GET"

type ЧислоИлиСтрока = number | string | boolean;
type БезБулева = Exclude<ЧислоИлиСтрока, boolean>;
// Результат: number | string

Почему never? Потому что never — это «пустой» тип, который исчезает из union. Когда TypeScript применяет распределительный условный тип к union, каждый элемент обрабатывается отдельно, и те, которые расширяют U, заменяются на never. А X | never = X для любого X.

Extract<T, U> — извлечение типов

Extract — противоположность Exclude. Он оставляет только те типы из union, которые расширяют U:

// Определение Extract (упрощённо):
type Extract<T, U> = T extends U ? T : never;

// Пример:
type Смешанный = string | number | boolean | null;
type ТолькоСтроки = Extract<Смешанный, string>;
// Результат: string

type Массивы = string[] | number[] | string | number;
type ТолькоМассивы = Extract<Массивы, any[]>;
// Результат: string[] | number[]

NonNullable<T> — удаление null и undefined

// Определение NonNullable:
type NonNullable<T> = T extends null | undefined ? never : T;

// Пример:
type Тип1 = NonNullable<string | null>;           // string
type Тип2 = NonNullable<string | null | undefined>; // string
type Тип3 = NonNullable<string>;                    // string

// Очень полезно при работе с API:
function найтиПользователя(id: number): Пользователь | null {
  // ...реализация
}

// NonNullable убирает null:
type ПользовательБезNull = NonNullable<ReturnType<typeof найтиПользователя>>;
// Пользователь

ReturnType<T> и Parameters<T>

Эти два типа мы уже подробно рассматривали в секции об infer. Напомним кратко:

type ReturnType<T extends (...args: any) => any> =
  T extends (...args: any) => infer R ? R : any;

type Parameters<T extends (...args: any) => any> =
  T extends (...args: infer P) => any ? P : never;

// ConstructorParameters — для классов:
type ConstructorParameters<T extends abstract new (...args: any) => any> =
  T extends abstract new (...args: infer P) => any ? P : never;

InstanceType<T> — тип экземпляра класса

type InstanceType<T extends abstract new (...args: any) => any> =
  T extends abstract new (...args: any) => infer R ? R : any;

class Пользователь {
  constructor(public имя: string, public возраст: number) {}
}

type ТипПользователя = InstanceType<typeof Пользователь>;
// Пользователь

Заметьте, что InstanceType и ConstructorParameters используют abstract new — это обрабатывает как обычные, так и абстрактные классы.

Awaited<T> — раскрытие Promise

Начиная с TypeScript 4.5, появился встроенный Awaited<T>, который рекурсивно раскрывает Promise (аналог нашего ГлубокийРезультат из секции 5):

type A1 = Awaited<Promise<string>>;                          // string
type A2 = Awaited<Promise<Promise<number>>>;               // number
type A3 = Awaited<Promise<Promise<Promise<boolean>>>>;    // boolean
type A4 = Awaited<string>;                                    // string

💡 Совет: Не нужно запоминать все определения utility types. Важно понимать принцип: условные типы + infer = извлечение и трансформация типов. Если забыли определение — смотрите исходники TypeScript на GitHub, они читаемы.

9. Распределительные условные типы (Distributive Conditional Types)

Это, пожалуй, самая неочевидная и самая «опасная» часть условных типов. Распределительные условные типы — это ситуация, когда TypeScript автоматически применяет условный тип к каждому элементу union-типа по отдельности. Это происходит неявно, и многие разработчики не осознают этого поведения.

Правило такое: если условный тип применяется к голому (naked) типовому параметру, TypeScript автоматически распределит его по элементам union:

// "Голый" типовой параметр T — нет обёрток:
type ЯвляетсяСтрокой<T> = T extends string ? "да" : "нет";

// Когда T — union, TypeScript распределяет:
type Результат = ЯвляетсяСтрокой<string | number | boolean>;

// TypeScript делает это автоматически:
// 1. ЯвляетсяСтрокой<string>  → "да"
// 2. ЯвляетсяСтрокой<number>  → "нет"
// 3. ЯвляетсяСтрокой<boolean> → "нет"
// Объединяем: "да" | "нет" | "нет" = "да" | "нет"

// Без распределения (обёрнули в кортеж):
type НеРаспределяет<T> = [T] extends [string] ? "да" : "нет";

type Результат2 = НеРаспределяет<string | number | boolean>;
// Результат: "нет" (весь union проверяется целиком)

Вот в чём суть: когда TypeScript видит T extends string ? X : Y и T — это A | B | C, он вычисляет результат для каждого элемента отдельно и объединяет. Это как если бы вы вызвали функцию для каждого элемента массива и собрали результаты.

Это полезный механизм, но он может неожиданно сработать. Вот классический пример:

// Вы думаете, что получите "да" | "нет", но...
type Строка<T> = T extends string ? "является строкой" : "не является строкой";

// Тут всё ожидаемо:
type Т1 = Строка<string>;        // "является строкой"
type Т2 = Строка<number>;        // "не является строкой"

// А тут распределение!
type Т3 = Строка<string | number>;
// = Строка<string> | Строка<number>
// = "является строкой" | "не является строкой"

// Но есть ловушка с never:
type Строка2<T> = T extends string ? true : false;
type Т4 = Строка2<string | never>;
// = Строка2<string> | Строка2<never>
// = true | false = true | false

Почему это важно? Потому что распределительное поведение может привести к неожиданным результатам, особенно когда вы работаете с union-типами, которые приходят извне (из generic-параметров, из переменных и т.д.).

Как отключить распределение?

Если вы хотите, чтобы union-тип проверялся целиком, а не поэлементно, оберните типовой параметр в кортеж или объект:

// Распределительный (T — голый):
type D1<T> = T extends string ? "да" : "нет";

// Не-распределительный (T обёрнут в [T]):
type D2<T> = [T] extends [string] ? "да" : "нет";

// Сравните:
type A = D1<"a" | "b" | number>;  // "да" | "нет" (распределено)
type B = D2<"a" | "b" | number>;  // "нет" (проверяется целиком: "a"|"b"|number extends string? Нет)

// Ещё пример:
type IsStringUnion<T> = [T] extends [string] ? true : false;

type C = IsStringUnion<string>;            // true
type D = IsStringUnion<"hello" | "world">; // true (union строк расширяет string)
type E = IsStringUnion<string | number>;   // false (не каждая часть расширяет string)

Принцип простой: обернули в [T] — распределение отключилось. Это стандартный приём, который используется во многих utility types.

10. Условные типы с union-типами

Union-типы и условные типы тесно связаны. Как мы уже видели, распределительные условные типы автоматически обрабатывают каждый элемент union по отдельности. Но что происходит, когда union появляется в правой части условия (в позиции extends)?

// Union в позиции extends (справа):
type ЯвляетсяЧисломИлиСтрокой<T> = T extends number | string ? "да" : "нет";

// Когда T — один тип:
type A = ЯвляетсяЧисломИлиСтрокой<number>;  // "да"
type B = ЯвляетсяЧисломИлиСтрокой<boolean>; // "нет"

// Когда T — union (распределение!):
type C = ЯвляетсяЧисломИлиСтрокой<number | boolean | string>;
// Распределение по T:
// ЯвляетсяЧисломИлиСтрокой<number>   → "да"
// ЯвляетсяЧисломИлиСтрокой<boolean>  → "нет"
// ЯвляетсяЧисломИлиСтрокой<string>   → "да"
// Результат: "да" | "нет" | "да" = "да" | "нет"

А вот что происходит, когда и левая, и правая стороны — union-типы:

// Извлечение элементов массива из union:
type ЭлементыИзМассивов<T> = T extends (infer U)[] ? U : never;

// Union массивов:
type A = ЭлементыИзМассивов<string[] | number[] | boolean[]>;
// = ЭлементыИзМассивов<string[]> | ЭлементыИзМассивов<number[]> | ЭлементыИзМассивов<boolean[]>
// = string | number | boolean

// А если смешать массивы и не-массивы:
type B = ЭлементыИзМассивов<string[] | number | boolean[]>;
// = string | never | boolean
// = string | boolean

Теперь давайте рассмотрим практически полезный паттерн — фильтрация union-типа по условию:

// Тип, который оставляет только строковые свойства объекта
type СтроковыеСвойства<T> = {
  [K in keyof T as T[K] extends string ? K : never]: T[K]
};

interface Пользователь {
  имя: string;
  возраст: number;
  email: string;
  активен: boolean;
}

type Толькостроки = СтроковыеСвойства<Пользователь>;
// { имя: string; email: string }

// А если хотим только числовые:
type ЧисловыеСвойства<T> = {
  [K in keyof T as T[K] extends number ? K : never]: T[K]
};

type Толькочисла = ЧисловыеСвойства<Пользователь>;
// { возраст: number }

Здесь мы использовали условные типы совместно с mapped types и ключевым as для переименования ключей. Если свойство не проходит условие extends string, мы возвращаем never, что effectively удаляет ключ из объекта.

11. Вложенные условные типы

Вложенные условные типы — это когда ветка «then» или «else» самого условия содержит ещё одно условие. Это аналог вложенных if/else в обычном коде. Рассмотрим пошагово:

// Простой пример вложенного условия:
type ОписаниеТипа<T> =
  T extends string
    ? T extends "" ? "пустая строка" : "непустая строка"
    : T extends number
      ? T extends 0 ? "ноль" : "ненулевое число"
      : T extends boolean
        ? "булево значение"
        : "что-то другое";

type A1 = ОписаниеТипа<"">;     // "пустая строка"
type A2 = ОписаниеТипа<"привет">; // "непустая строка"
type A3 = ОписаниеТипа<0>;       // "ноль"
type A4 = ОписаниеТипа<42>;      // "ненулевое число"
type A5 = ОписаниеТипа<true>;    // "булево значение"
type A6 = ОписаниеТипа<null>;    // "что-то другое"

Вложенные условия могут быстро стать трудночитаемыми. Когда вложенность превышает 2-3 уровня, лучше использовать отдельные именованные типы:

// Лучше — разбить на именованные типы:
type ОписаниеСтроки<T extends string> =
  T extends "" ? "пустая строка" : "непустая строка";

type ОписаниеЧисла<T extends number> =
  T extends 0 ? "ноль" : "ненулевое число";

type ОписаниеБулева<T extends boolean> = "булево значение";

type ОписаниеТипа2<T> =
  T extends string ? ОписаниеСтроки<T> :
  T extends number ? ОписаниеЧисла<T> :
  T extends boolean ? ОписаниеБулева<T> :
  "что-то другое";

Теперь рассмотрим более сложный пример — вложенные infer для извлечения типов из глубоко вложенных структур:

// Извлекаем тип возвращаемого значения функции, которая возвращает Promise
type РезультатАсинхроннойФункции<T> =
  T extends (...args: any[]) => Promise<infer R>
    ? R
    : T extends (...args: any[]) => infer R
      ? R
      : never;

async function получитьДанные(): Promise<{ id: number; name: string }> {
  return { id: 1, name: "test" };
}

function обычный(): string {
  return "";
}

type R1 = РезультатАсинхроннойФункции<typeof получитьДанные>;
// { id: number; name: string }

type R2 = РезультатАсинхроннойФункции<typeof обычный>;
// string

Вот ещё один пример — извлечение типов из React-подобных пропсов:

// Тип компонента, который принимает пропсы и возвращает JSX
type Компонент<P = {}> = {
  (props: P): any;
  displayName?: string;
};

// Извлекаем тип пропсов из компонента
type ПропсыКомпонента<T> =
  T extends Компонент<infer P>
    ? P
    : T extends (props: infer P) => any
      ? P
      : {};

interface КнопкаПропсы {
  label: string;
  onClick: () => void;
  variant?: "primary" | "secondary";
}

const Кнопка: Компонент<КнопкаПропсы> = (props) => null;

type КнопкаПропсыИзвлечённые = ПропсыКомпонента<typeof Кнопка>;
// { label: string; onClick: () => void; variant?: "primary" | "secondary" }

💡 Совет: TypeScript имеет лимит глубины вложенности условных типов (проверьте — обычно 50 уровней). На практике старайтесь не превышать 5-7 уровней вложенности, чтобы типы оставались читаемыми.

12. Практические паттерны

Теперь давайте посмотрим на реальные паттерны, которые вы будете использовать каждый день. Условные типы — это не теория; они лежат в основе многих привычных вам инструментов.

Паттерн 1: Типобезопасные обработчики событий

// Определяем события и их пейлоады
interface КартаСобытий {
  "пользователь:вход": { userId: string; timestamp: Date };
  "пользователь:выход": { userId: string };
  "сообщение:отправлено": { от: string; кому: string; текст: string };
  "сообщение:прочитано": { messageId: string };
}

// Типобезопасная функция подписки
type Обработчик<E extends keyof КартаСобытий> =
  (пейлоад: КартаСобытий[E]) => void;

function подписаться<E extends keyof КартаСобытий>(
  событие: E,
  обработчик: Обработчик<E>
): void {
  // регистрация обработчика...
}

// TypeScript знает точный тип пейлоада!
подписаться("пользователь:вход", (пейлоад) => {
  console.log(пейлоад.userId);    // OK — string
  console.log(пейлоад.timestamp); // OK — Date
});

// Ошибка: неизвестное событие
// подписаться("неизвестное", () => {}); // Ошибка компиляции!

Паттерн 2: Типы ответов API

// Маппинг эндпоинтов на типы запросов и ответов
interface APIMap {
  "/users": {
    GET: { response: Пользователь[]; query: { page: number; limit: number } };
    POST: { response: Пользователь; body: { name: string; email: string } };
  };
  "/posts": {
    GET: { response: Пост[]; query: { authorId?: string } };
  };
}

// Извлекаем тип ответа для эндпоинта
type ОтветЭндпоинта<
  Путь extends keyof APIMap,
  Метод extends keyof APIMap[Путь]
> = APIMap[Путь][Метод] extends { response: infer R } ? R : never;

// Извлекаем тип тела запроса
type ТелоЗапроса<
  Путь extends keyof APIMap,
  Метод extends keyof APIMap[Путь]
> = APIMap[Путь][Метод] extends { body: infer B } ? B : never;

// Использование:
type Т1 = ОтветЭндпоинта<"/users", "GET">;     // Пользователь[]
type Т2 = ОтветЭндпоинта<"/users", "POST">;    // Пользователь
type Т3 = ОтветЭндпоинта<"/posts", "GET">;     // Пост[]
type Т4 = ТелоЗапроса<"/users", "POST">;        // { name: string; email: string }

// Универсальная функция запроса
async function api<
  Путь extends keyof APIMap,
  Метод extends keyof APIMap[Путь]
>(
  путь: Путь,
  метод: Метод,
  опции?: ТелоЗапроса<Путь, Метод>
): Promise<ОтветЭндпоинта<Путь, Метод>> {
  const ответ = await fetch(путь, {
    method: метод as string,
    body: опции ? JSON.stringify(опции) : undefined,
  });
  return ответ.json();
}

// Типобезопасные запросы:
const пользователи = await api("/users", "GET"); // Пользователь[]
const новый = await api("/users", "POST", {
  name: "Анна",
  email: "anna@test.com",
}); // Пользователь

Паттерн 3: Условное логирование по типу

// Тип, который определяет уровень логирования по типу ошибки
type УровеньЛогирования<T> =
  T extends Error & { status: number }
    ? T["status"] extends 400 | 401 | 403
      ? "warn"
      : T["status"] extends 500
        ? "error"
        : "info"
    : "debug";

class HTTPОшибка extends Error {
  constructor(public status: number, message: string) {
    super(message);
  }
}

function залогировать<E extends Error>(ошибка: E): УровеньЛогирования<E> {
  return "debug" as any;
}

const err401 = new HTTPОшибка(401, "Unauthorized");
const уровень = залогировать(err401); // тип: "warn"

Паттерн 4: Типобезопасные slug-маршруты

// Извлекаем параметры из шаблона маршрута
type ПараметрыМаршрута<T extends string> =
  T extends `${string}:${infer Param}/${infer Rest}`
    ? { [K in Param | keyof ПараметрыМаршрута<Rest>]: string }
    : T extends `${string}:${infer Param}`
      ? { [K in Param]: string }
      : {};

type P1 = ПараметрыМаршрута<"/users/:id">;           // { id: string }
type P2 = ПараметрыМаршрута<"/users/:id/posts/:postId">; // { id: string; postId: string }
type P3 = ПараметрыМаршрута<"/static">;               // {}

💡 Совет: Самый частый сценарий использования условных типов — это работа с API и обработчиками событий, когда типы данных зависят от входных параметров. Начните с простых маппингов и постепенно усложняйте.

13. Лучшие практики и распространённые ошибки

Практика 1: Всегда обрабатывайте ветку else

// Плохо — нет ветки else, при несовпадении будет never:
type Плохо<T> = T extends string ? "строка" : never;

// Хорошо — есть fallback:
type Хорошо<T> = T extends string ? "строка" : "не строка";

// Или используйте never осознанно:
type Строгий<T extends string> = T extends "да" | "нет" ? T : never;

Практика 2: Избегайте слишком длинных цепочек

// Плохо — монолит:
type ВсёВОдном<T> =
  T extends string ? "строка" :
  T extends number ? "число" :
  T extends boolean ? "булево" :
  T extends any[] ? "массив" :
  T extends Promise<any> ? "промис" :
  T extends Function ? "функция" :
  T extends object ? "объект" :
  "неизвестно";

// Лучше — разбить на составные части:
type ЯвляетсяСтрокой<T> = T extends string ? true : false;
type ЯвляетсяЧислом<T> = T extends number ? true : false;
type ЯвляетсяМассивом<T> = T extends any[] ? true : false;

// И потом комбинировать:
type Категория<T> =
  ЯвляетсяСтрокой<T> extends true ? "строка" :
  ЯвляетсяЧислом<T> extends true ? "число" :
  ЯвляетсяМассивом<T> extends true ? "массив" :
  "другое";

Практика 3: Используйте extends для ограничения generic

// Плохо — T может быть чем угодно:
function получитьДанные<T>(url: string): Promise<T> {
  return fetch(url).then(r => r.json());
}

// Лучше — T ограничен реалистичными типами:
function получитьДанные2<T extends Record<string, any>>(url: string): Promise<T> {
  return fetch(url).then(r => r.json());
}

// Или с дефолтным значением:
function получитьДанные3<T extends Record<string, any> = Record<string, unknown>>(
  url: string
): Promise<T> {
  return fetch(url).then(r => r.json());
}

Распространённые ошибки

❌ Ошибка 1: Забыли, что infer работает только в extends

infer можно использовать только в позиции extends. Вы не можете написать type Bad<T> = infer T extends string ? ....

❌ Ошибка 2: Неожиданное распределение

Если T — union и «голый», TypeScript распределит условный тип. Оберните в [T], если хотите проверять целиком.

❌ Ошибка 3: Слишком глубокая вложенность

Более 5-7 уровней вложенности делают типы нечитаемыми. Разбивайте на именованные подтипы.

❌ Ошибка 4: Использование any вместо infer

Часто вместо T extends Array<any> лучше использовать T extends Array<infer E>, чтобы извлечь тип элемента.

❌ Ошибка 5: Забыли про never в fallback

Если условие не совпадает и вы не возвращаете тип — TypeScript вернёт never, что может привести к неожиданным ошибкам дальше по цепочке.

Чек-лист для условных типов

✅ Прочитайте условный тип вслух как предложение — понятно ли?

✅ Обработка union-типов — ожидаемое распределение или нет?

✅ Есть ли fallback-ветка (else)?

✅ Используете infer только где нужно (не для всего типа)?

✅ Вложенность не превышает 5-7 уровней?

✅ Именованные подтипы для повторного использования?

14. Итоги урока

Давайте подведём итоги того, что мы изучили в этом важном уроке:

Что мы узнали:

1. Синтаксис условных типов — T extends U ? X : Y позволяет создавать типы, которые зависят от условий. Это аналог if/else на уровне типов.

2. Infer — ключевое слово для «извлечения» внутренних типов из структур (массивов, промисов, функций, кортежей). Infer превращает условные типы из простого переключателя в инструмент анализа.

3. Распределительные условные типы — когда голый типовой параметр T является union, TypeScript автоматически применяет условие к каждому элементу. Оберните в [T] для отключения распределения.

4. Встроенные utility types — Exclude, Extract, NonNullable, ReturnType, Parameters, Awaited — все они построены на условных типах с infer.

5. Вложенные условные типы —позволяетусловные цепочки, но старайтесь не углубляться более чем на 5-7 уровней.

6. Практические паттерны — типобезопасные обработчики событий, типы ответов API, извлечение параметров маршрутов.

7. Лучшие практики — всегда обрабатывайте else-ветку, избегайте глубокой вложенности, используйте infer вместо any, разбивайте на именованные подтипы.

Условные типы — это один из тех инструментов, которые начинают казаться сложными, но быстро становятся незаменимыми. Как только вы привыкнете думать в терминах «если тип X, то тип Y», вы начнёте видеть возможности для условных типов повсюду.

Вспомните аналогию из начала урока: банк с документами. Теперь ваша «типовая система» — это не просто библиотекарь, который ставит книгу на полку. Это умный клерк, который анализирует документ клиента, извлекает нужную информацию и автоматически выдаёт правильную форму. И всё это — на этапе компиляции, без единой строки кода во время выполнения.

В следующем уроке мы рассмотрим template literal types — ещё один мощный механизм TypeScript, который работает в тесной связке с условными типами.

Предыдущий урок
7.4 — Mapped Types
Следующий урок
7.6 — Template Literal Types

Тест

8 вопросов

Conditional type с infer

Premium

Дистрибутивные условные типы

Premium