Дженерики в TypeScript: как писать переиспользуемый код
Практика3 минРедакция Innovialabs
Коротко
Дженерик — это тип-параметр, который позволяет одной функции или типу работать с разными данными, не теряя точной типизации и не скатываясь в any.
Зачем нужны дженерики
Дженерик — это параметр для типа. Как функция принимает значения, так дженерик принимает типы. Это позволяет написать код один раз и использовать его с разными данными, сохраняя точные подсказки и проверки.
Без дженериков есть два плохих варианта: дублировать функцию под каждый тип или использовать any и потерять типизацию.
function first(items: any[]): any {
return items[0];
}
const x = first([1, 2, 3]); // any — TypeScript больше ничего не проверяет
С дженериком тип сохраняется:
function first<T>(items: T[]): T | undefined {
return items[0];
}
const n = first([1, 2, 3]); // number | undefined
const s = first(['a', 'b']); // string | undefined
T подставляется автоматически из аргумента — указывать его вручную обычно не нужно.
Обобщённые типы и интерфейсы
Самый частый практический пример — обёртка ответа API:
interface ApiResponse<T> {
data: T;
error: string | null;
}
interface User {
id: string;
name: string;
}
async function getJson<T>(url: string): Promise<ApiResponse<T>> {
const res = await fetch(url);
if (!res.ok) return { data: null as T, error: `HTTP ${res.status}` };
return { data: (await res.json()) as T, error: null };
}
const result = await getJson<User[]>('/api/users');
result.data[0].name; // string, работает автодополнение
Здесь T указан явно, потому что TypeScript не может узнать, что вернёт сервер. Помните: это обещание компилятору, а не проверка. Для данных извне в критичных местах добавляйте валидацию в рантайме (например, схемой).
Ограничения: extends
Иногда от типа нужно, чтобы у него было определённое поле. Для этого есть ограничение через extends:
function byId<T extends { id: string }>(items: T[], id: string): T | undefined {
return items.find((item) => item.id === id);
}
Функция примет массив любых объектов, у которых есть id: string, и вернёт тот же тип, а не урезанный.
Ограничение с keyof позволяет безопасно брать поле объекта:
function pick<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { id: '1', name: 'Анна', age: 30 };
pick(user, 'name'); // string
pick(user, 'email'); // ошибка компиляции
Типы по умолчанию
Как у параметров функции, у дженериков бывают значения по умолчанию:
interface Paginated<T = unknown> {
items: T[];
page: number;
total: number;
}
const a: Paginated = { items: [], page: 1, total: 0 }; // T = unknown
const b: Paginated<User> = { items: [], page: 1, total: 0 }; // T = User
Встроенные утилиты
TypeScript содержит готовые обобщённые типы. Они закрывают большинство повседневных задач:
| Утилита | Что делает |
|---|---|
Partial<T> | все поля необязательные — удобно для форм обновления |
Required<T> | все поля обязательные |
Pick<T, K> | оставляет только выбранные поля |
Omit<T, K> | убирает выбранные поля |
Record<K, V> | объект с ключами K и значениями V |
ReturnType<F> | тип результата функции |
Awaited<T> | тип, который получится после await |
type UserUpdate = Partial<Omit<User, 'id'>>;
// { name?: string }
Полный список есть в официальной документации.
Частые ошибки
- Дженерик ради дженерика. Если
Tвстречается только один раз в сигнатуре, он, скорее всего, не нужен — достаточно обычного типа. - Слишком много параметров.
<T, U, V, W>читать тяжело. Если без них нельзя, давайте осмысленные имена:TData,TError. - Каст вместо проверки.
as Tпрячет ошибки данных. Для внешних данных нужна валидация. - Забытое ограничение. Без
extendsнельзя обращаться к полямT— компилятор не знает, что они есть.