Введение: что такое Generics и почему они важны
TypeScript — это строго типизированный язык, который добавляет в JavaScript мощную систему типов. Однако строгая типизация иногда мешает писать гибкий и переиспользуемый код. Именно здесь на помощь приходят Generics (дженерики, обобщенные типы).
Generics — это инструмент, который позволяет создавать компоненты (функции, классы, интерфейсы), работающие с различными типами данных, не теряя при этом контроля типов. Представьте, что вы пишете функцию, которая должна работать и с числами, и со строками, и с массивами. Без дженериков вам пришлось бы либо использовать any (что убивает всю пользу от типизации), либо создавать отдельные функции для каждого типа данных. Generics решают эту проблему элегантно и безопасно.
В этой статье мы разберем, зачем нужны дженерики в TypeScript, и, самое главное, как их использовать на практике — от простых функций до сложных классов и ограничений типов.
Основная часть
1. Generics в функциях: от теории к практике
Самый простой и частый пример использования дженериков — это функции, которые принимают и возвращают данные одного и того же типа. Рассмотрим классическую задачу: функция, возвращающая первый элемент массива.
Плохой подход (без Generics):
// Пример с any — теряем информацию о типе
function getFirst(arr: any[]): any {
return arr[0];
}
const num = getFirst([1, 2, 3]); // тип num — any, мы не знаем, что это число
Правильный подход (с Generics):
// Используем обобщенный тип T
function getFirst<T>(arr: T[]): T {
return arr[0];
}
const num = getFirst([1, 2, 3]); // тип num — number
const str = getFirst(['a', 'b', 'c']); // тип str — string
const bool = getFirst([true, false]); // тип bool — boolean
Как это работает? Мы объявляем параметр типа <T> (название может быть любым, но по соглашению используются T, U, V). TypeScript автоматически выводит конкретный тип на основе переданного аргумента. Это называется выводом типов.
Вы также можете явно указать тип при вызове функции:
const num = getFirst<number>([1, 2, 3]); // явное указание типа
Еще один полезный пример — функция, которая возвращает случайный элемент массива:
function getRandom<T>(items: T[]): T {
const randomIndex = Math.floor(Math.random() * items.length);
return items[randomIndex];
}
const randomNumber = getRandom([10, 20, 30]); // тип — number
const randomName = getRandom(['Анна', 'Иван', 'Мария']); // тип — string
2. Generics в интерфейсах и типах
Дженерики отлично работают и с интерфейсами. Это позволяет создавать гибкие структуры данных, которые могут хранить значения разных типов.
Представьте, что мы хотим описать API-ответ, который может содержать данные любого типа:
interface ApiResponse<T> {
status: number;
message: string;
data: T;
}
// Использование для разных типов данных
const userResponse: ApiResponse<{ id: number; name: string }> = {
status: 200,
message: 'Успешно',
data: { id: 1, name: 'Алексей' }
};
const listResponse: ApiResponse<string[]> = {
status: 200,
message: 'Список получен',
data: ['яблоко', 'банан', 'апельсин']
};
Также можно использовать дженерики с псевдонимами типов (type):
type Result<T> = {
success: boolean;
value: T;
error?: string;
};
const successResult: Result<number> = { success: true, value: 42 };
const errorResult: Result<string> = { success: false, error: 'Ошибка загрузки', value: '' };
Обратите внимание: мы можем комбинировать дженерики с другими типами. В примере выше error — опциональное поле, а value — обязательное.
3. Ограничения Generics (Generic Constraints)
Иногда мы хотим ограничить, какие типы могут быть использованы в дженерике. Например, мы пишем функцию, которая должна работать только с объектами, имеющими определенное свойство. Для этого используются ограничения (constraints) с помощью ключевого слова extends.
Рассмотрим пример: функция, которая выводит длину свойства length:
// Без ограничения — TypeScript выдаст ошибку
function logLength<T>(arg: T): void {
console.log(arg.length); // Ошибка! У T может не быть свойства length
}
// С ограничением — гарантируем, что у типа есть length
interface HasLength {
length: number;
}
function logLength<T extends HasLength>(arg: T): void {
console.log(arg.length); // Все ок!
}
logLength('Привет'); // строка имеет length — 6
logLength([1, 2, 3]); // массив имеет length — 3
logLength({ length: 10 }); // объект с полем length — 10
// logLength(123); // Ошибка! У числа нет length
Еще один практический пример — функция, которая извлекает значение по ключу из объекта:
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const person = { name: 'Анна', age: 30, city: 'Москва' };
const name = getProperty(person, 'name'); // тип — string
const age = getProperty(person, 'age'); // тип — number
// getProperty(person, 'salary'); // Ошибка! У объекта нет ключа 'salary'
Здесь мы использовали два параметра типа: T — тип объекта, K — тип ключа, ограниченный с помощью keyof T (все ключи объекта T). Это мощный паттерн для безопасной работы с объектами.
4. Generics в классах
Дженерики также применяются в классах, что позволяет создавать переиспользуемые компоненты. Классический пример — реализация стека (LIFO):
class Stack<T> {
private items: T[] = [];