Дженерики и infer
В краткой версии дженерики появились в двух местах: в функции pick<T, K> с ограничениями и в типе ApiListResponse<T> для ответов API. Мы пользовались ими как «параметрами типа», не спрашивая, как они работают. Теперь зададим этот вопрос: что происходит, когда компилятор встречает дженерик? Как он выбирает значение параметра? Почему иногда выводит unknown, а иногда — точный тип? И что за магия infer, которая вскрывает внутренности типов?
Эти знания — не академизм. Каждый раз, когда ты видишь в библиотеке тип вроде UseQueryResult<TData, TError> или пишешь обёртку над fetch, ты работаешь с инстанциацией дженериков. Понимание механики превращает чтение типов из сторонних библиотек из головоломки в чтение объявлений. Базовая глава TypeScript Handbook: Generics покрывает 80% этого материала — всё остальное наращивается сверху неё.
Инстанциация: что происходит под капотом
Заголовок раздела «Инстанциация: что происходит под капотом»Дженерик — это не «тип с дыркой». Это шаблон, по которому компилятор строит конкретный тип в каждой точке вызова. Этот процесс называется инстанциацией (instantiation):
function identity<T>(value: T): T { return value;}
const a = identity('hello'); // T инстанцируется как 'hello' (литерал!)const b = identity<number>(42); // T инстанцируется как number (явно)const c: string = identity('hello'); // T инстанцируется как string (контекст)Три вызова — три разных инстанцированных сигнатуры: (value: 'hello') => 'hello', (value: number) => number, (value: string) => string. Компилятор создаёт их на лету и проверяет вызов против конкретного экземпляра.
Ключевой механизм — вывод из аргументов (inference): если параметр типа используется в позиции аргумента, компилятор берёт фактический тип аргумента и подставляет его в T. Отсюда правило: дженерик работает, когда тип связан с позицией, из которой можно вывести — аргументом, возвращаемым значением, значением по умолчанию.
Есть позиции, из которых вывести нельзя:
function create<T>(): T { // T нигде не связан с аргументами — выведется unknown throw new Error('stub');}
const x = create(); // unknownconst y = create<string>(); // string — только явная аннотация спасаетТакие дженерики — код с запахом: либо добавь параметр, связывающий T с входом, либо честно верни unknown/any без дженерика.
Ограничения: extends как контракт
Заголовок раздела «Ограничения: extends как контракт»T extends U — не наследование, а ограничение множества допустимых значений параметра. Плюс два бонуса: внутри функции/типа T знает свойства U, а при инстанциации с нарушением — ошибка.
// T должен быть объектом с числовым полем lengthfunction first<T extends { length: number }>(collection: T): T[number] { // T[number] — индексный доступ: тип элемента по числовому ключу if (collection.length === 0) { throw new Error('Пусто'); } return collection[0];}
first('hello'); // 'h' — string подходит: у неё есть lengthfirst([1, 2, 3]); // 1first({ length: 1 }); // ошибка типизации элемента, но сам объект подходитfirst(42); // ошибка: у number нет lengthСамая частая пара в типобезопасных утилитах — T extends object, K extends keyof T:
function get<T extends object, K extends keyof T>(obj: T, key: K): T[K] { return obj[key]; // TS знает: key точно существует в obj}
const user = { id: 1, name: 'Влад' };get(user, 'name'); // stringget(user, 'role'); // ошибка: 'role' не входит в keyof userkeyof T — union всех ключей. Ограничение K extends keyof T превращает второй аргумент из «любая строка» в «ровно те строки, что есть в объекте». Это работает и с литералами: get(user, 'name') выводит K = 'name', потому что компилятор предпочитает наиболее специфичный вариант из ограничения.
Множественные ограничения через пересечение:
interface HasId { id: number;}interface HasTimestamp { createdAt: Date;}
function mergeById<T extends HasId & HasTimestamp>(items: T[]): Map<number, T> { const map = new Map<number, T>(); for (const item of items) { map.set(item.id, item); // есть id — гарантировано ограничением } return map;}Значения по умолчанию
Заголовок раздела «Значения по умолчанию»Параметр дженерика может иметь значение по умолчанию — применяется, когда вывести не удалось:
interface ApiResponse<T = unknown> { data: T; status: number;}
declare const res: ApiResponse; // T = unknowndeclare const res2: ApiResponse<User>; // T = UserПаттерн «дженерик + дефолт» — стандарт для контейнеров и обёрток: Promise<T> без дефолта (нет смысла обещать неизвестное), но Ref<T = unknown>, Result<T = void, E = Error> — норма.
Дженерики в интерфейсах и классах
Заголовок раздела «Дженерики в интерфейсах и классах»Интерфейс с дженериком — описание формы контейнера, не конкретного значения:
interface Repository<T> { findById(id: number): Promise<T | null>; save(entity: T): Promise<void>; findAll(filter?: Partial<T>): Promise<T[]>;}
// Один интерфейс — много реализаций под разные сущностиclass UserRepository implements Repository<User> { async findById(id: number): Promise<User | null> { /* ... */ return null; } async save(entity: User): Promise<void> { /* ... */ } async findAll(filter?: Partial<User>): Promise<User[]> { /* ... */ return []; }}Обрати внимание: Partial<T> в сигнатуре — это mapped-тип, тема следующей главы, но здесь он показывает практическое следствие дженериков: фильтр может быть частичной формой сущности, и типы это выражают без копипасты.
В классах дженерик может быть на уровне класса или метода:
class Box<T> { constructor(private value: T) {}
map<U>(fn: (value: T) => U): Box<U> { return new Box(fn(this.value)); // T класса → U метода }
unwrap(): T { return this.value; }}
const box = new Box('hello').map((s) => s.length); // Box<number>box.unwrap(); // numbermap<U> — методный дженерик: каждый вызов инстанцируется заново, U выводится из возврата колбэка. Это тот же паттерн, что и в Array.prototype.map, только с явным контейнером.
infer: деконструкция типов
Заголовок раздела «infer: деконструкция типов»Теперь главное. В условном типе T extends U ? X : Y можно использовать infer, чтобы вырвать часть типа U наружу как новый параметр:
// Извлекаем элемент массива, кортежа или Promisetype ElementOf<T> = T extends Array<infer U> ? U : T;type Resolved<T> = T extends Promise<infer R> ? R : T;
type A = ElementOf<number[]>; // numbertype B = ElementOf<string[]>; // stringtype C = Resolved<Promise<{ uptime: number }>>; // { uptime: number }type D = Resolved<string>; // string — не Promise, вернули как естьКак это читать: «если T — это массив чего-то, назови это что-то U и верни его; иначе верни T». Компилятор сопоставляет T с паттерном Array<infer U> как с шаблоном — это называется типоуровневое сопоставление с образцом (type-level pattern matching). Механика infer детально разобрана в TypeScript Handbook: Conditional Types.
infer работает и с функциями:
type ReturnOf<T> = T extends (...args: never[]) => infer R ? R : never;type ArgsOf<T> = T extends (...args: infer P) => unknown ? P : never;
declare const handler: (event: 'click', x: number) => string;type R = ReturnOf<typeof handler>; // stringtype P = ArgsOf<typeof handler>; // [event: 'click', x: number]Это ровно то, как реализованы встроенные ReturnType и Parameters — в следующей главе мы увидим их точные определения из lib.d.ts.
Вариантность: почему массивы — это ловушка
Заголовок раздела «Вариантность: почему массивы — это ловушка»Вариантность (variance) определяет, как совместимость типов A и B переносится на конструкции из них. Ключевые случаи:
- ковариантность:
Cat extends Animal⇒Cat[]совместим сAnimal[](массивы ковариантны в TS); - контравариантность:
() => Catсовместим с() => Animalнаоборот — в позиции аргумента подтип и супертип меняются местами; - инвариантность: только точное совпадение.
class Animal { name = 'animal';}class Cat extends Animal { meow() {}}
const cats: Cat[] = [new Cat()];const animals: Animal[] = cats; // ок — ковариантностьanimals.push(new Animal()); // отлично, но cats[0] — уже не Cat!
const cat = cats[0];cat.meow(); // рантайм-ошибка: Animal не умеет meowTS допускает это из соображений практичности (настоящая безопасность потребовала бы инвариантных массивов и сломала бы весь существующий JS). Поэтому в коде, который пишет в параметр-массив, ковариантность — минное поле. Спасает readonly — объявление массивов и кортежей как readonly T[] заодно включает корректную проверку вариантности, о чём напоминает TypeScript Handbook: Generics, раздел variance:
function printNames(animals: readonly Animal[]): void { for (const a of animals) console.log(a.name); // animals.push(new Animal()); // ошибка — readonly}
printNames(cats); // безопасно: писать нельзяПозиции параметров функций — контравариантны: (x: Animal) => void можно присвоить переменной типа (x: Cat) => void (обработчик, принимающий любое животное, справится и с котом). TS при strictFunctionTypes проверяет позиции аргументов строго контравариантно, а позиции возврата — ковариантно. Это одна из причин, почему strictFunctionTypes включён в strict и почему лучше не отключать.
Типичные ошибки и грабли
Заголовок раздела «Типичные ошибки и грабли»1. Дженерик без связи с аргументами.
// ПЛОХО: T не выведется — вернётся unknownfunction load<T>(): Promise<T> { return fetch('/api').then((r) => r.json());}Хорошо: async function load<T>(parser: (raw: unknown) => T): Promise<T> — свяжи вывод с входом.
2. Лишние ограничения.
// ПЛОХО: запрещает массивы, строки, Map — всё, что индексируетсяfunction first<T>(arr: T[]): T { return arr[0];}Хорошо: first<T extends { length: number }> или просто T[], если нужен именно массив. Каждое ограничение — отрезанные сценарии использования.
3. any вместо unknown в теле дженерика.
any «заражает»: присваивания теряют проверку. Внутри обобщённого кода неизвестное значение — unknown, и сужай его предикатами.
4. Инстанциация через as вместо связи типов.
// ПЛОХОfunction wrap<T>(value: unknown): Box<T> { return new Box(value as T);}Здесь T ни к чему не привязан — вызов wrap<User>(raw) ничего не проверяет. Привяжи: function wrap<T>(value: T): Box<T>.
5. Забыть readonly на ковариантных позициях.
Функция принимает T[] и только читает — объяви readonly T[]. Иначе кто-то передаст Cat[] как Animal[] и сломает его изнутри. Дешёвая защита, нулевая стоимость.
6. Дженерик-класс без явной инстанциации.
const repo = new UserRepository(); // если класс дженерик — T выведется из аргументов конструктора, часто в unknownЕсли у конструктора нет параметра типа T — инстанцируй явно: new Repository<User>() или сделай фабрику с выводом.
Вопросы на собеседовании
Заголовок раздела «Вопросы на собеседовании»1. Что такое инстанциация дженерика и когда она происходит?
Подстановка конкретного типа в параметр шаблона в точке использования. Происходит при каждом вызове функции/создании объекта с дженериком: компилятор выводит тип из аргументов или берёт явную аннотацию и проверяет код против конкретной версии сигнатуры.
2. Зачем нужны ограничения extends? Два эффекта.
Во-первых, доступ к членам ограничения внутри реализации: без T extends { length: number } обращение collection.length — ошибка. Во-вторых, ранняя проверка вызовов: нарушение ограничения ловится на этапе компиляции, а не в рантайме.
3. Что делает infer и где используется?
Извлекает тип из позиции внутри шаблона в условном типе: T extends Array<infer U> ? U : T. Основа встроенных ReturnType, Parameters, Awaited, элементов кортежей, вывода props в React. Это сопоставление с образцом на уровне типов.
4. Почему T[] ковариантен и чем это опасно?
Массивы ковариантны: Cat[] совместим с Animal[], что допускает запись «чужого» подтипа и порчу исходного массива. Защита — readonly T[] в параметрах читающих функций; писать в такой параметр нельзя на уровне типов.
5. Чем unknown отличается от дженерика-заглушки?
unknown — конкретный верхний тип: с ним нельзя работать без сужения. Дженерик — переменная, которая инстанцируется конкретным типом вызывающим кодом. Функция f<T>(x: T): T даёт вызывающему контроль над типом; f(x: unknown): unknown — нет.
6. Как вывести дженерик из возвращаемого значения колбэка?
Поместить его в позицию возврата метода: map<U>(fn: (x: T) => U): Box<U> — U выводится из типа того, что вернул колбэк. Компилятор решает систему уравнений «аргумент → параметр», включая вложенные функции.
7. Значения по умолчанию у дженериков — когда нужны?
Когда тип может быть выведен, но не всегда: контейнеры с опциональным содержимым (Ref<T = unknown>), API-клиенты с дефолтным типом ошибки (Result<T, E = Error>). Дефолт срабатывает только при невозможности вывода, явная аннотация его перебивает.
Практика
Заголовок раздела «Практика»- Реализуй
head<T extends readonly unknown[]>(arr: T): T[0] | undefinedдля кортежей и массивов так, чтобыhead([1, 'a'] as const)вернул1 | 'a' | undefined, а обращение кT[0]не требовало non-null assertion. - Напиши класс
ResultBox<T, E = Error>с методамиmap<U>(fn: (v: T) => U): ResultBox<U, E>иflatMap<U>(fn: (v: T) => ResultBox<U, E>): ResultBox<U, E>, гдеflatMapубирает вложенность без кастов. - Напиши тип
First<T extends readonly unknown[]>, извлекающий первый элемент кортежа черезinfer, иDropFirst<T>, отбрасывающий его. Проверь на['a', 1, true]. - Реализуй функцию
groupBy<T, K extends string>(items: readonly T[], keyFn: (item: T) => K): Record<K, T[]>, где вызовgroupBy(users, (u) => u.role)даёт ключи ровно из литерального union ролей, а неstring. - Найди в своём проекте функцию с параметром-массивом, которая его не мутирует, и добавь
readonlyк параметру; зафиксируй, какие вызовы с «расширенными» массивами (например,Cat[]вместоAnimal[]) стали возможны.
Критерий результата: все типы выводятся без явной аннотации на вызове, нет any в реализации, readonly-варианты принимают обычные массивы, но не дают мутировать внутри функций.
Что почитать
Заголовок раздела «Что почитать»- TypeScript Handbook: Generics — основы и типичные паттерны.
- TypeScript Handbook: Conditional Types —
inferи дистрибутивность. - TypeScript Handbook: Classes — дженерики в классах,
this-типы. - Demystifying TypeScript Variance Annotations — вариантность на пальцах.
- Total TypeScript — использование satisfies и дженериков в UI-типах — практика на реальных кейсах.