Generics в TypeScript: зачем нужны и как использовать
TypeScript — это мощный инструмент, который добавляет статическую типизацию в JavaScript. Однако, если вы только начинаете изучать TypeScript, вы могли столкнуться с ситуацией, когда приходится писать один и тот же код для разных типов данных. Именно здесь на помощь приходят Generics (обобщенные типы или дженерики). В этой статье мы разберем, что такое Generics, зачем они нужны и как их использовать на практике.
Что такое Generics и почему они важны?
Generics — это механизм TypeScript, который позволяет создавать компоненты (функции, классы, интерфейсы), работающие с различными типами данных, а не с каким-то одним конкретным. Представьте, что вы пишете функцию, которая должна работать и с числами, и со строками, и с массивами. Без дженериков вам пришлось бы либо использовать тип any (что убивает всю суть TypeScript), либо создавать несколько копий одной и той же функции.
Основные преимущества Generics:
- Типобезопасность — вы сохраняете строгую типизацию, но при этом код остается гибким.
- Переиспользование кода — одна функция или класс могут работать с разными типами.
- Читаемость — код становится более понятным и самодокументируемым.
Базовый синтаксис Generics
Давайте начнем с самого простого примера. Допустим, нам нужна функция, которая возвращает переданный ей аргумент. Без Generics мы бы написали так:
function identity(arg: number): number {
return arg;
}
Но что, если нам нужно передать строку? Придется создавать новую функцию. С помощью Generics мы делаем так:
function identity<T>(arg: T): T {
return arg;
}
// Использование:
let output1 = identity<string>("Привет");
let output2 = identity<number>(42);
let output3 = identity<boolean>(true);
Здесь <T> — это параметр типа (или переменная типа). Когда мы вызываем функцию, TypeScript автоматически подставляет нужный тип. Вы можете использовать любую букву: T, U, V или даже осмысленные названия вроде Type.
Кстати, TypeScript может сам вывести тип, поэтому часто можно писать без явного указания:
let output = identity("Привет"); // TypeScript сам поймет, что T = string
Работа с массивами и несколькими типами
Одно из самых частых применений Generics — работа с массивами. Предположим, мы хотим написать функцию, которая принимает массив и возвращает первый элемент:
function getFirstElement<T>(arr: T[]): T | undefined {
return arr[0];
}
const numbers = [1, 2, 3];
const firstNumber = getFirstElement(numbers); // тип number | undefined
const strings = ["a", "b", "c"];
const firstString = getFirstElement(strings); // тип string | undefined
Обратите внимание: мы использовали T[] — это то же самое, что Array<T>. А что если функция принимает два разных типа? Легко!
function merge<T, U>(obj1: T, obj2: U): T & U {
return { ...obj1, ...obj2 };
}
const result = merge({ name: "Анна" }, { age: 25 });
// result имеет тип { name: string; } & { age: number; }
// Фактически: { name: string; age: number; }
Ограничения Generics (Constraints)
Иногда нужно, чтобы дженерик работал не с любым типом, а только с теми, которые имеют определенные свойства. Для этого используются ограничения с помощью ключевого слова extends.
Например, мы хотим написать функцию, которая получает длину объекта. Очевидно, что не у всех типов есть свойство length. Давайте это проверим:
function getLength<T extends { length: number }>(item: T): number {
return item.length;
}
console.log(getLength("Hello")); // 5
console.log(getLength([1, 2, 3])); // 3
// console.log(getLength(123)); // Ошибка! У числа нет length
// console.log(getLength({ name: "Test" })); // Ошибка! Нет свойства length
Это мощный инструмент: вы не просто говорите "работай с любым типом", а накладываете условия. В реальных проектах это часто используется для работы с API, базами данных и сложными структурами.
Другой пример — ограничение по типу ключа объекта:
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { id: 1, name: "Иван", role: "admin" };
const userName = getProperty(user, "name"); // тип string
const userId = getProperty(user, "id"); // тип number
// getProperty(user, "email"); // Ошибка! Свойства email нет в объекте
Generic классы и интерфейсы
Дженерики работают не только с функциями, но и с классами и интерфейсами. Это особенно полезно при создании структур данных, таких как стеки, очереди или репозитории.
Пример Generic класса — простой стек:
class Stack<T> {
private items: T[] = [];
push(item: T): void {
this.items.push(item);
}
pop(): T | undefined {
return this.items.pop();
}
peek(): T | undefined {
return this.items[this.items.length - 1];
}
get size(): number {
return this.items.length;
}
}
// Использование:
const numberStack = new Stack<number>();
numberStack.push(10);
numberStack.push(20);
console.log(numberStack.pop()); // 20
const stringStack = new Stack<string>();
stringStack.push("TypeScript");
console.log(stringStack.peek()); // "TypeScript"
А вот пример Generic интерфейса. Допустим, мы работаем с ответами от API:
interface ApiResponse<T> {
status: number;
data: T;
message: string;
}
interface User {
id: number;
name: string;
}
interface Product {
id: number;
title: string;
price: number;
}
// Используем один интерфейс для разных типов данных:
const userResponse: ApiResponse<User> = {
status: 200,
data: { id: 1, name: "Анна" },
message: "Успешно"
};
const productResponse: ApiResponse