Введение в Generics в TypeScript
TypeScript — это строго типизированный язык, который расширяет возможности JavaScript, добавляя статическую типизацию. Однако одной из самых мощных и гибких его возможностей является механизм Generics (дженериков). Если вы когда-нибудь писали код, который должен работать с разными типами данных, но при этом сохранять безопасность типов, то Generics — это именно то, что вам нужно.
Представьте, что вы создаете универсальную функцию, которая может принимать массивы чисел, строк или объектов. Без дженериков вам пришлось бы либо использовать тип any (что убивает всю пользу от TypeScript), либо писать отдельные функции для каждого типа. Generics решают эту проблему элегантно и безопасно. В этой статье мы разберем, что такое Generics, зачем они нужны и как их эффективно использовать в реальных проектах.
Что такое Generics и зачем они нужны?
Generics — это механизм, который позволяет создавать компоненты (функции, классы, интерфейсы), способные работать с различными типами данных, не теряя при этом информации о типах. Вместо того чтобы фиксировать конкретный тип, вы используете параметр типа, который подставляется в момент вызова.
Проблема без Generics
Допустим, вы хотите написать функцию, которая возвращает первый элемент массива. Без дженериков это может выглядеть так:
function getFirstElement(arr: any[]): any {
return arr[0];
}
const num = getFirstElement([1, 2, 3]); // Тип 'any' — мы потеряли информацию
console.log(num.toUpperCase()); // Ошибка во время выполнения, но TypeScript молчит
Здесь мы используем any, и TypeScript не может предупредить нас о том, что num — число, а не строка. Это приводит к потенциальным ошибкам во время выполнения.
Решение с Generics
Теперь перепишем ту же функцию с использованием дженериков:
function getFirstElement<T>(arr: T[]): T {
return arr[0];
}
const num = getFirstElement([1, 2, 3]); // Тип 'number'
const str = getFirstElement(['a', 'b', 'c']); // Тип 'string'
// num.toUpperCase(); // Ошибка компиляции — TypeScript знает, что num — число
Символ <T> — это параметр типа. Он действует как placeholder: когда мы вызываем функцию с массивом чисел, T становится number, и возвращаемое значение также получает тип number. Таким образом, мы сохраняем строгую типизацию без потери гибкости.
Как использовать Generics: основные сценарии
Generics применяются не только в функциях, но и в классах, интерфейсах и типах. Рассмотрим наиболее распространенные сценарии.
Generics в функциях
Самый простой и частый случай — это функции, которые работают с данными разных типов. Например, функция для создания пары ключ-значение:
function createPair<K, V>(key: K, value: V): { key: K; value: V } {
return { key, value };
}
const pair = createPair('id', 123); // { key: string; value: number }
console.log(pair.key.toUpperCase()); // Работает
// pair.key.toFixed(); // Ошибка — key это строка
Здесь мы используем два параметра типа: K для ключа и V для значения. TypeScript автоматически выводит типы на основе аргументов.
Generics в классах
Дженерики отлично подходят для создания универсальных классов, таких как стеки, очереди или хранилища данных:
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];
}
}
const numberStack = new Stack<number>();
numberStack.push(10);
numberStack.push(20);
console.log(numberStack.pop()); // 20
const stringStack = new Stack<string>();
stringStack.push('hello');
// stringStack.push(42); // Ошибка — ожидается string
Класс Stack<T> может работать с любым типом, но при этом сохраняет безопасность: в стек чисел нельзя положить строку.
Generics в интерфейсах и типах
Интерфейсы с дженериками позволяют создавать гибкие структуры данных. Например, типизированный ответ API:
interface ApiResponse<T> {
data: T;
status: number;
message: string;
}
interface User {
id: number;
name: string;
}
async function fetchUser(id: number): Promise<ApiResponse<User>> {
const response = await fetch(`/api/users/${id}`);
return response.json();
}
// Использование
fetchUser(1).then(res => {
console.log(res.data.name); // TypeScript знает, что data — это User
});
В этом примере ApiResponse<T> — универсальный интерфейс, который может адаптироваться под любую структуру данных. Это особенно полезно при работе с REST API.
Продвинутые техники: ограничения и утилиты
Иногда нужно наложить ограничения на параметр типа. Например, гарантировать, что тип имеет определенное свойство. Для этого используется ключевое слово extends.
Ограничения с extends
interface HasLength {
length: number;
}
function logLength<T extends HasLength>(item: T): void {
console.log(item.length);
}
logLength('hello'); // 5 — строка имеет length
logLength([1, 2, 3]); // 3 — массив имеет length
// logLength(123); // Ошибка — число не имеет length
Здесь T extends HasLength означает, что тип T должен иметь свойство length. Это гарантирует, что мы можем безопасно обращаться к item.length.
Утилитарные типы с Generics
TypeScript предоставляет встроенные утилиты, основанные на дженериках. Например, Partial<T>, Pick<T, K> или Record<K, V>:
interface User {
id: number;
name: string;
email: string;
}
// Partial делает все поля необязательными
function updateUser(id: number, updates: Partial<User>): void {
// Обновление пользователя
}
updateUser(1, { name: 'New Name' }); // Ра