Перейти к содержимому

Дженерики и 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(); // unknown
const y = create<string>(); // string — только явная аннотация спасает

Такие дженерики — код с запахом: либо добавь параметр, связывающий T с входом, либо честно верни unknown/any без дженерика.

T extends U — не наследование, а ограничение множества допустимых значений параметра. Плюс два бонуса: внутри функции/типа T знает свойства U, а при инстанциации с нарушением — ошибка.

// T должен быть объектом с числовым полем length
function 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 подходит: у неё есть length
first([1, 2, 3]); // 1
first({ 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'); // string
get(user, 'role'); // ошибка: 'role' не входит в keyof user

keyof 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 = unknown
declare 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(); // number

map<U> — методный дженерик: каждый вызов инстанцируется заново, U выводится из возврата колбэка. Это тот же паттерн, что и в Array.prototype.map, только с явным контейнером.

Теперь главное. В условном типе T extends U ? X : Y можно использовать infer, чтобы вырвать часть типа U наружу как новый параметр:

// Извлекаем элемент массива, кортежа или Promise
type ElementOf<T> = T extends Array<infer U> ? U : T;
type Resolved<T> = T extends Promise<infer R> ? R : T;
type A = ElementOf<number[]>; // number
type B = ElementOf<string[]>; // string
type 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>; // string
type P = ArgsOf<typeof handler>; // [event: 'click', x: number]

Это ровно то, как реализованы встроенные ReturnType и Parameters — в следующей главе мы увидим их точные определения из lib.d.ts.

Вариантность: почему массивы — это ловушка

Заголовок раздела «Вариантность: почему массивы — это ловушка»

Вариантность (variance) определяет, как совместимость типов A и B переносится на конструкции из них. Ключевые случаи:

  • ковариантность: Cat extends AnimalCat[] совместим с 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 не умеет meow

TS допускает это из соображений практичности (настоящая безопасность потребовала бы инвариантных массивов и сломала бы весь существующий 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 не выведется — вернётся unknown
function 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>). Дефолт срабатывает только при невозможности вывода, явная аннотация его перебивает.

  1. Реализуй head<T extends readonly unknown[]>(arr: T): T[0] | undefined для кортежей и массивов так, чтобы head([1, 'a'] as const) вернул 1 | 'a' | undefined, а обращение к T[0] не требовало non-null assertion.
  2. Напиши класс 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 убирает вложенность без кастов.
  3. Напиши тип First<T extends readonly unknown[]>, извлекающий первый элемент кортежа через infer, и DropFirst<T>, отбрасывающий его. Проверь на ['a', 1, true].
  4. Реализуй функцию groupBy<T, K extends string>(items: readonly T[], keyFn: (item: T) => K): Record<K, T[]>, где вызов groupBy(users, (u) => u.role) даёт ключи ровно из литерального union ролей, а не string.
  5. Найди в своём проекте функцию с параметром-массивом, которая его не мутирует, и добавь readonly к параметру; зафиксируй, какие вызовы с «расширенными» массивами (например, Cat[] вместо Animal[]) стали возможны.

Критерий результата: все типы выводятся без явной аннотации на вызове, нет any в реализации, readonly-варианты принимают обычные массивы, но не дают мутировать внутри функций.