← назад к разделу

Рано или поздно вы пишете функцию или структуру данных, которая должна работать с разными типами одинаково: обёртка ответа API, кэш, функция «вернуть первый элемент массива». Дженерики (generics) — это способ написать такой код один раз, не теряя проверку типов. Разберём, зачем они нужны и как ими пользоваться.

Зачем нужны дженерики

Представим функцию, которая возвращает первый элемент массива. Без дженериков есть два плохих варианта.

Первый — написать отдельную функцию под каждый тип. Это дублирование: firstNumber, firstString, firstUser — и так до бесконечности.

Второй — использовать any, чтобы принять что угодно:

function first(arr: any[]): any {
  return arr[0];
}

const n = first([1, 2, 3]); // тип n — any, проверки потеряны
n.toUpperCase(); // компилятор молчит, а в runtime будет ошибка

Здесь мы выключили проверку типов: n имеет тип any, и TypeScript уже не подскажет, что у числа нет метода toUpperCase. Дженерики решают ровно эту проблему — позволяют связать тип результата с типом входа.

Короткая формула: дженерик — это «параметр типа», который подставляется при вызове, как обычный аргумент, только для типов.

Обобщённые функции

Перепишем first с дженериком. В угловых скобках после имени объявляем параметр типа — по соглашению его называют T (от type):

function first<T>(arr: T[]): T | undefined {
  return arr[0];
}

const n = first([1, 2, 3]);        // n: number | undefined
const s = first(["a", "b"]);       // s: string | undefined

Тип T не задан заранее — он выводится из аргумента. Передали массив чисел — T стал number, и результат тоже number. Передали строки — T стал string. Один код, полная проверка типов.

Чаще всего T выводится автоматически, но при желании его можно указать явно:

const x = first<string>(["a", "b"]); // x: string | undefined

Параметров типа может быть несколько. Классический пример — функция, объединяющая два объекта:

function merge<A, B>(a: A, b: B): A & B {
  return { ...a, ...b };
}

const result = merge({ name: "Ann" }, { age: 30 });
// result: { name: string } & { age: number }
result.name; // ок
result.age;  // ок

Обобщённые типы и интерфейсы

Дженерики работают не только в функциях. Часто нужен обобщённый тип — структура, форма которой одинакова, а наполнение разное. Типичный случай — обёртка ответа API:

interface ApiResponse<T> {
  data: T;
  status: number;
  error: string | null;
}

// подставляем конкретный тип при использовании
type User = { id: number; name: string };

const userResponse: ApiResponse<User> = {
  data: { id: 1, name: "Ann" },
  status: 200,
  error: null,
};

userResponse.data.name; // тип known: string

То же самое можно записать через type:

type Box<T> = {
  value: T;
};

const numberBox: Box<number> = { value: 42 };
const stringBox: Box<string> = { value: "hello" };

Параметр типа можно «прокидывать» дальше — например, обёртка для постраничного списка переиспользует ApiResponse:

type Page<T> = {
  items: T[];
  total: number;
};

type UsersPage = ApiResponse<Page<User>>;

Ограничения через extends

Иногда дженерик должен работать не с любым типом, а с тем, у которого есть нужное свойство. Например, функция «вернуть длину» имеет смысл только для значений, у которых есть length. Если оставить T свободным, компилятор справедливо запретит обращаться к .length — он же не знает, что оно там есть.

Решение — ограничение (constraint) через extends. Оно говорит: «T может быть любым, но обязан соответствовать вот этой форме»:

function logLength<T extends { length: number }>(value: T): T {
  console.log(value.length); // теперь .length гарантированно есть
  return value;
}

logLength("hello");     // ок: у строки есть length
logLength([1, 2, 3]);   // ок: у массива есть length
logLength(42);          // ошибка: у числа нет length

Частый приём — ограничить один параметр типа ключами другого. Оператор keyof T даёт объединение имён свойств T, и мы требуем, чтобы ключ принадлежал объекту:

function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const user = { id: 1, name: "Ann" };

getProperty(user, "name"); // тип результата: string
getProperty(user, "id");   // тип результата: number
getProperty(user, "email"); // ошибка: "email" нет среди ключей

T[K] здесь — это «тип свойства K у объекта T». Компилятор точно знает, что getProperty(user, "id") вернёт number, а не что попало. Это и есть переиспользуемый типобезопасный код: одна функция, корректная для любого объекта и любого его поля.

Utility types — готовые дженерики

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

type User = {
  id: number;
  name: string;
  email: string;
};

// Partial<T> — все поля становятся необязательными.
// Удобно для функций обновления, где меняют часть полей.
function updateUser(id: number, patch: Partial<User>) {
  // patch может быть { name: "Bob" } или { email: "...", name: "..." }
}

// Pick<T, K> — выбрать только перечисленные поля.
type UserPreview = Pick<User, "id" | "name">;
// { id: number; name: string }

// Record<K, V> — объект с ключами K и значениями V.
// Например, словарь "роль → список прав".
type Permissions = Record<string, string[]>;
const perms: Permissions = {
  admin: ["read", "write"],
  guest: ["read"],
};

Есть и другие — Omit (исключить поля), Readonly (сделать все поля доступными только для чтения), Required (сделать все обязательными). Главное — понимать, что это не магия, а обычные дженерики, и при необходимости похожие вы можете написать сами.

Коротко

  • Дженерик — параметр типа, который подставляется при вызове и связывает типы входа и выхода; альтернатива дублированию кода и опасному any.
  • В функциях T обычно выводится из аргументов автоматически, но его можно указать и явно.
  • Обобщёнными бывают не только функции, но и type / interface — это форма с переменным наполнением (обёртки ответов, контейнеры, списки).
  • Ограничение T extends ... сужает допустимые типы и даёт доступ к гарантированным свойствам; keyof и T[K] делают доступ к полям типобезопасным.
  • Utility types (Partial, Pick, Record, Omit, Readonly) — готовые дженерики стандартной библиотеки для трансформации типов.

Что почитать дальше

  • Базовые типы TypeScript — фундамент, на котором строятся дженерики.
  • Сужение типов и type guards — как TypeScript уточняет тип внутри ветвлений.
  • Классы и декораторы — дженерики работают и в классах, например в обобщённых репозиториях.