TypeScript Generics: пишем гибкий код без дублирования

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Введение: почему без дженериков TypeScript теряет половину силы?

Представьте, что вы — архитектор, который каждый раз проектирует здание с нуля, даже если нужно просто построить ещё один такой же гараж. Примерно так чувствует себя разработчик, который пишет одну и ту же функцию для чисел, строк и массивов, не используя TypeScript generics. Звучит знакомо? Если вы уже освоили базовые типы в TypeScript, но чувствуете, что пишете слишком много дублирующегося кода, эта статья для вас.

Generics (дженерики) — это механизм, который позволяет создавать компоненты (функции, классы, интерфейсы), работающие с разными типами, а не с одним конкретным. Вместо того чтобы копировать код, вы пишете его один раз, а тип подставляется автоматически. В этой статье мы разберём, как работает обобщённое программирование в TypeScript, покажем реальные примеры кода и дадим практические советы, которые сделают ваш код чище и безопаснее.

Мы — PythonLib, и сегодня говорим о TypeScript, потому что без него современная фронтенд-разработка немыслима. Погнали!

Что такое дженерики в TypeScript и зачем они нужны?

Давайте начнём с простого примера. Допустим, вы хотите написать функцию, которая возвращает переданный ей аргумент (функция-identity).

// Без дженериков — приходится использовать any
function identity(arg: any): any {
    return arg;
}

// Или писать перегрузки для каждого типа
function identityNumber(arg: number): number { return arg; }
function identityString(arg: string): string { return arg; }

Первый вариант убивает типизацию — вы теряете информацию о типе. Второй — заставляет плодить кучу одинаковых функций. Дженерики TypeScript решают эту проблему элегантно:

function identity<T>(arg: T): T {
    return arg;
}

const num = identity<number>(42);    // тип number
const str = identity('hello');       // тип string (вывод типа)
const bool = identity(true);         // тип boolean

Символ <T> — это параметр типа. Вы можете назвать его как угодно (часто используют T, U, V), но лучше давать осмысленные имена, если их много. Когда вы вызываете функцию, TypeScript подставляет реальный тип вместо T. Если тип не указан явно, компилятор выведет его автоматически из аргумента.

Таким образом, обобщённые типы (Generics) позволяют сохранить строгую типизацию там, где раньше приходилось использовать any. Это снижает количество багов и делает код самодокументируемым.

Синтаксис дженериков: от простого к сложному

Generic функции

Мы уже видели базовый пример. Давайте усложним: функция, которая принимает массив и возвращает его первый элемент.

function getFirst<T>(arr: T[]): T | undefined {
    return arr[0];
}

const firstNumber = getFirst([1, 2, 3]);       // number | undefined
const firstString = getFirst(['a', 'b', 'c']); // string | undefined

Обратите внимание: T[] — это сокращение от Array<T>. Вы можете использовать любую форму.

Generic интерфейсы

Интерфейсы с дженериками — мощный инструмент для описания структур данных. Например, типизированный ответ API:

interface ApiResponse<T> {
    data: T;
    status: number;
    message: string;
}

// Использование
const userResponse: ApiResponse<{ id: number; name: string }> = {
    data: { id: 1, name: 'Alice' },
    status: 200,
    message: 'OK'
};

Теперь один интерфейс может описывать ответы для пользователей, товаров, заказов — достаточно подставить нужный тип.

Generic классы

Классы тоже могут быть обобщёнными. Классический пример — очередь (Queue):

class Queue<T> {
    private data: T[] = [];

    push(item: T): void {
        this.data.push(item);
    }

    pop(): T | undefined {
        return this.data.shift();
    }
}

const numberQueue = new Queue<number>();
numberQueue.push(1);
numberQueue.push(2);
// numberQueue.push('string'); // Ошибка! Только числа

const stringQueue = new Queue<string>();
stringQueue.push('hello');
stringQueue.push('world');

Класс Queue<T> может работать с любым типом, но экземпляр жёстко привязан к одному типу. Это даёт типобезопасность на этапе компиляции.

Ограничения дженериков (Generic Constraints)

Иногда вы хотите разрешить не любой тип, а только те, которые имеют определённые свойства. Для этого используются ограничения с ключевым словом extends.

Представьте, что нужно написать функцию, которая получает свойство объекта по имени:

function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
    return obj[key];
}

const person = { name: 'Bob', age: 30, city: 'Moscow' };
const name = getProperty(person, 'name');  // string
const age = getProperty(person, 'age');    // number
// getProperty(person, 'salary'); // Ошибка: 'salary' не входит в keyof T

Здесь K extends keyof T означает, что K может быть только одним из ключей объекта T. Это избавляет от ошибок во время выполнения.

Другой пример: мы хотим работать только с объектами, у которых есть свойство length:

interface HasLength {
    length: number;
}

function logLength<T extends HasLength>(arg: T): T {
    console.log(arg.length);
    return arg;
}

logLength('hello');      // строка имеет length
logLength([1, 2, 3]);    // массив имеет length
// logLength(123);        // Ошибка: число не имеет length

Ограничения — это мощный способ сделать дженерики более безопасными и выразительными.

Продвинутые приёмы: условные типы и вывод

Когда вы освоите базовые дженерики, можно перейти к более сложным конструкциям. Например, условные типы (Conditional Types) позволяют выбирать тип в зависимости от условия:

type IsString<T> = T extends string ? 'yes' : 'no';

type A = IsString<string>;  // 'yes'
type B = IsString<number>;  // 'no'

А вот пример из реальной жизни — тип, который извлекает тип элемента из массива:

type ArrayElement<T> = T extends (infer U)[] ? U : never;

type NumberArray = ArrayElement<number[]>;     // number
type StringArray = ArrayElement<string[]>;     // string
type NotArray = ArrayElement<number>;          // never

Ключевое слово infer позволяет "вывести" тип из структуры. Это основа для создания продвинутых ути

Блоги

Рекомендаций по книгам