Введение: Что такое Generics и почему они важны?
При изучении TypeScript вы быстро сталкиваетесь с ситуацией, когда нужно написать функцию или класс, который может работать с разными типами данных. Без специальных инструментов вам пришлось бы либо дублировать код для каждого типа, либо использовать any, что убивает всю магию строгой типизации. Именно здесь на помощь приходят Generics (дженерики или обобщения).
Generics — это мощный механизм TypeScript, который позволяет создавать компоненты (функции, классы, интерфейсы), работающие с различными типами, сохраняя при этом строгую типизацию. Представьте, что вы пишете универсальный контейнер, который может хранить и яблоки, и книги, и числа, но при этом TypeScript точно знает, что именно лежит внутри в каждом конкретном случае.
В этой статье мы разберем, зачем нужны Generics, как их объявлять и использовать в реальных проектах. Вы узнаете, как сделать ваш код более гибким, безопасным и переиспользуемым.
Основы Generics: синтаксис и первая функция
Давайте начнем с простого примера. Допустим, нам нужна функция, которая принимает аргумент и возвращает его же (identity function). Без дженериков мы бы написали так:
function identity(arg: number): number {
return arg;
}
Но эта функция работает только с числами. Чтобы сделать её универсальной, мы можем использовать any:
function identity(arg: any): any {
return arg;
}
Проблема в том, что мы теряем информацию о типе. TypeScript не знает, что вернется именно тот же тип, что был передан. Вот тут и приходят на помощь Generics:
function identity<T>(arg: T): T {
return arg;
}
// Использование:
let output1 = identity<string>("Привет"); // тип string
let output2 = identity<number>(42); // тип number
Разберем синтаксис:
<T>— это параметр типа. Вы можете использовать любую букву, но по соглашению чаще всего используютT(Type),U,V.arg: T— аргумент имеет типT.: Tпосле скобок — функция возвращает значение типаT.
Теперь TypeScript знает: если вы передали строку, то и на выходе будет строка. Если число — число. Вывод типов часто работает автоматически, поэтому можно писать просто identity("Привет") — TypeScript сам поймет, что T — это string.
Generics в функциях: работа с массивами и ограничения
Чаще всего дженерики используют для работы с массивами или коллекциями. Рассмотрим функцию, которая возвращает первый элемент массива:
function getFirst<T>(arr: T[]): T | undefined {
return arr[0];
}
const numbers = [1, 2, 3];
const firstNumber = getFirst(numbers); // тип number
const strings = ["a", "b", "c"];
const firstString = getFirst(strings); // тип string
Обратите внимание: T[] — это сокращенная запись для Array<T>. Функция работает с любым массивом, но возвращает элемент именно того типа, который хранится в массиве.
Ограничения (Constraints) с помощью extends
Иногда нужно, чтобы тип T имел определенные свойства. Например, мы хотим получить длину объекта. Не все типы имеют свойство length, поэтому мы можем наложить ограничение:
interface HasLength {
length: number;
}
function getLength<T extends HasLength>(arg: T): number {
return arg.length;
}
getLength("строка"); // OK, строка имеет length
getLength([1, 2, 3]); // OK, массив имеет length
getLength(42); // Ошибка! Число не имеет length
Ключевое слово extends в данном контексте означает "расширяет" или "соответствует". Мы говорим TypeScript: "T должен быть типом, у которого есть свойство length".
Generics в интерфейсах и классах
Дженерики отлично работают не только с функциями, но и с интерфейсами и классами. Это особенно полезно при создании структур данных.
Generic интерфейсы
Представьте, что мы создаем интерфейс для ответа API. Ответ может содержать разные типы данных:
interface ApiResponse<T> {
status: number;
data: T;
message?: string;
}
// Использование:
interface User {
id: number;
name: string;
}
const userResponse: ApiResponse<User> = {
status: 200,
data: { id: 1, name: "Анна" }
};
const productResponse: ApiResponse<string[]> = {
status: 200,
data: ["ноутбук", "телефон"]
};
Теперь один интерфейс покрывает все возможные ответы, сохраняя типизацию поля data.
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];
}
isEmpty(): boolean {
return this.items.length === 0;
}
}
// Использование:
const numberStack = new Stack<number>();
numberStack.push(10);
numberStack.push(20);
console.log(numberStack.pop()); // 20
const stringStack = new Stack<string>();
stringStack.push("Привет");
stringStack.push("Мир");
console.log(stringStack.pop()); // "Мир"
Обратите внимание: мы не можем случайно положить строку в стек чисел — TypeScript выдаст ошибку на этапе компиляции.
Продвинутые техники: несколько параметров и дефолтные типы
Вы можете использовать несколько параметров типа. Например, функция, которая объединяет два объекта разных типов:
function merge<T, U>(obj1: T, obj2: U): T & U {
return { ...obj1, ...obj2 };
}
const merged = merge(
{ name: "Иван" },
{ age: 30 }
);
// merged