Урок 32: Введение в Generics. Проблема, которую они решают

Сегодня нас ждет один из самых важных поворотов на нашем пути, это Generics (Дженерики или Обобщенные типы).

Если предыдущие уроки были типы, интерфейсы, функции. То Generics это тот инструмент, который позволит нам строить на этом фундаменте гибкие, переиспользуемые и максимально безопасные конструкции. Мы подходим к тому уровню, где TypeScript раскрывает свою настоящую мощь, превращаясь из просто статического типизатора в инструмент для создания по-настоящему надежного и выразительного кода. Давайте же разберемся, в чем проблема, которую решают дженерики и как они делают наш код лучше.

Проблема: как написать универсальную функцию без потери типизации?

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

Вот как мы могли бы попытаться написать её на TypeScript, используя наши текущие знания.

Попытка №1: Использование конкретного типа.

typescript
function identity(arg: number): number {
  return arg;
}

const result = identity(5); // OK, result имеет тип number
const errorResult = identity("text"); // Ошибка: Argument of type 'string' is not assignable to parameter of type 'number'.

Это типобезопасно, но абсолютно негибко. Что, если нам нужно будет работать со строками? Или с массивами? Придется для каждого типа писать свою функцию: identityStringidentityArray и так далее. Это неверный путь, ведущий к дублированию кода.

Попытка №2: Использование типа any.

Столкнувшись с негибкостью конкретных типов, мы, естественно, вспоминаем про any. Он же может быть чем угодно! Давайте попробуем.

typescript
function identity(arg: any): any {
  return arg;
}

const numResult = identity(5); // OK, тип numResult - any
const strResult = identity("text"); // OK, тип strResult - any
const arrResult = identity([1, 2, 3]); // OK, тип arrResult - any

Отлично! Функция стала универсальной. Она принимает что угодно и возвращает что угодно. Проблема гибкости решена. Но какою ценой? Мы полностью потеряли информацию о типе!

Посмотрите: переменные numResultstrResult и arrResult имеют тип any. TypeScript больше не знает, что numResult это число, strResult строка, а arrResult массив. Следовательно, мы теряем всю мощь TypeScript: автодополнение, проверку на ошибки и рефакторинг.

typescript
const numResult = identity(5);
numResult.toFixed(); // Ошибки не будет во время компиляции...
// Но если бы мы передали строку, а потом вызвали toFixed(), была бы ошибка времени выполнения!

const strResult = identity("text");
strResult.toUpperCase(); // OK, но TypeScript не подсказывает этот метод, т.к. тип any

const arrResult = identity([1, 2, 3]);
arrResult.push(4); // OK, но опять же, без всякой подсказки и проверки.

Мы откатились к поведению обычного JavaScript. Функция стала гибкой, но небезопасной. Мы заменили одну проблему (негибкость) на другую, гораздо более серьезную (отсутствие типизации).

Перед нами встает дилемма: либо мы жертвуем гибкостью в угоду безопасности типов, либо жертвуем безопасностью типов в угоду гибкости. Оба варианта очевидно плохи.

Именно эту дилемму и призваны разрешить Generics. Они предлагают нам третий путь: сохранить и гибкость и полную безопасность типов.

Решение: Обобщенный тип (Generic) как параметр функции

Что если бы мы могли передать тип в качестве параметра? Примерно так же, как мы передаем значения. Мы бы сказали функции: «Вот тебе значение 5 и, кстати, его тип number. Используй это знание».

Именно так и работают дженерики! Они позволяют создавать компоненты, работающие с любыми типами, но при этом не теряющие информацию об этом типе.

Синтаксис дженериков использует угловые скобки (<>). Внутри них мы объявляем один или несколько параметров типа. По соглашению, их называют одной заглавной буквой, например T (от Type), UV или более описательно, если параметров несколько (например, KeyValue).

Давайте перепишем нашу функцию identity с использованием дженерика.

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

Что здесь происходит?

  1. Мы объявляем функцию identity.

  2. Сразу после имени функции, в угловых скобках, мы объявляем параметр типа T. Это своего рода объявление переменной, но для типа. Мы говорим: «Функция identity будет использовать некий тип T. Каким именно он будет, мы узнаем позже, в момент вызова функции».

  3. Далее, мы используем этот T везде, где нам нужна информация о типе: как тип для аргумента arg и как тип возвращаемого значения.

Теперь посмотрим, как эту функцию вызывать.

Способ 1: Явное указание типа при вызове.

typescript
// Мы явно говорим: "Функция identity, в этой работе параметр типа T = number"
const numResult = identity<number>(5); // тип numResult - number

// А здесь говорим: "T = string"
const strResult = identity<string>("text"); // тип strResult - string

// И здесь: "T = number[]"
const arrResult = identity<number[]>([1, 2, 3]); // тип arrResult - number[]

Теперь посмотрите на результат! Функция универсальна, но при этом numResult имеет точный тип numberstrResult — string, а arrResult — number[]. TypeScript снова в строю: он знает типы и может нам помочь.

Способ 2: Вывод типа (Type Inference).

TypeScript умный компилятор. Часто ему не нужно явно указывать тип. Он может вывести его из типа переданного аргумента.

typescript
// TypeScript видит, что аргумент 5 имеет тип number.
// Он понимает: "Ага, значит, в этот раз T = number".
const numResult = identity(5); // тип numResult - number (выведен автоматически)

// Аргумент "text" имеет тип string -> T = string.
const strResult = identity("text"); // тип strResult - string

// Аргумент [1,2,3] имеет тип number[] -> T = number[].
const arrResult = identity([1, 2, 3]); // тип arrResult - number[]

Это самый распространенный и предпочтительный способ использования дженериков. Он делает код чистым и лаконичным, не лишая его при этом всех преимуществ статической типизации.

Давайте убедимся, что типобезопасность работает в полной мере:

typescript
const numResult = identity(5);
numResult.toFixed(); // TypeScript ЗНАЕТ, что numResult - number и позволяет использовать toFixed()
// numResult.toUpperCase(); // Ошибка: Свойство 'toUpperCase' не существует у типа 'number'.

const strResult = identity("text");
strResult.toUpperCase(); // OK, TypeScript знает, что это строка.
// strResult.push(1); // Ошибка: Свойство 'push' не существует у типа 'string'.

const arrResult = identity([1, 2, 3]);
arrResult.push(4); // OK, TypeScript знает, что это массив чисел и у него есть метод push.
// arrResult.toFixed(); // Ошибка: Свойство 'toFixed' не существует у типа 'number[]'.

Вот оно! Идеальное решение. Мы получили и универсальность функции identity (она может работать с любым типом) и полную безопасность типов (TypeScript знает конкретный тип возвращаемого значения в каждом конкретном вызове).

Реальные кейсы использования Generics

Конечно, функция identity сама по себе не очень полезна. Она нужна была лишь для демонстрации концепции. Но принцип, который она иллюстрирует, лежит в основе бесчисленного множества реальных задач.

Давайте рассмотрим несколько практических примеров, где без дженериков было бы совсем туго.

1. Работа с массивами. Функция getLast.

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

Без дженериков мы снова упремся в выбор между any и конкретным типом.

typescript
// Плохо: функция работает только с числами
function getLastNumber(arr: number[]): number {
  return arr[arr.length - 1];
}

// Плохо: функция теряет информацию о типе
function getLastAny(arr: any[]): any {
  return arr[arr.length - 1];
}

const lastNumAny = getLastAny([1, 2, 3]); // тип any

С дженериками мы создаем универсальную и безопасную функцию:

typescript
function getLast<T>(arr: T[]): T | undefined {
  return arr[arr.length - 1];
}

// Вызовы
const lastNum = getLast([1, 2, 3]); // TypeScript выводит T = number, тип lastNum - number | undefined
const lastStr = getLast(["a", "b", "c"]); // T = string, тип lastStr - string | undefined
const lastBool = getLast([true, false]); // T = boolean, тип lastBool - boolean | undefined

// Теперь мы в безопасности!
if (lastNum) {
  console.log(lastNum.toFixed()); // Автодополнение и проверка типов работают!
}

Обратите внимание, мы добавили | undefined, так как теоретически массив может быть пустым. Это показывает, как дженерики прекрасно работают в union типах.

2. Функция merge, объединяющая два объекта.

Представьте, мы хотим объединить два объекта в один новый. Типы объектов могут быть разными.

typescript
function merge<U, V>(obj1: U, obj2: V): U & V {
  return { ...obj1, ...obj2 };
}

// Использование
const user = { name: "Max" };
const permissions = { level: "admin" };

const merged = merge(user, permissions);
// TypeScript сам вывел:
// U = { name: string }
// V = { level: string }
// Возвращаемый тип: { name: string } & { level: string }

console.log(merged.name); // OK, string
console.log(merged.level); // OK, string
// console.log(merged.age); // Ошибка: Свойства 'age' не существует у типа...

Мы объявили два параметра типа: U для типа первого объекта и V для типа второго. TypeScript brilliantly вывел их и смог определить, что возвращаемый тип, это их пересечение (U & V).

Практические задачи для закрепления

Давайте напишем несколько функций-утилит, чтобы почувствовать мощь дженериков.

Задача 1: Функция toArray.

Напишите универсальную функцию toArray, которая принимает один аргумент и возвращает массив, содержащий только этот аргумент. Тип массива должен определяться типом аргумента.

typescript
// Ваша реализация здесь...

// Проверка:
const numberArray = toArray(5); // должен быть тип number[]
const stringArray = toArray("hello"); // должен быть тип string[]

Решение задачи 1

typescript
function toArray<T>(item: T): T[] {
  return [item];
}

Задача 2: Функция getLength.

Напишите функцию getLength, которая принимает аргумент, у которого есть свойство length (например, строка, массив) и возвращает это значение. Используйте ограничение дженериков (о которых мы подробнее поговорим в следующем уроке), чтобы гарантировать, что переданный аргумент имеет свойство length.

Подсказка: Используйте extends<T extends { length: number }>

typescript
// Ваша реализация здесь...

// Проверка:
console.log(getLength("Hello")); // 5
console.log(getLength([1, 2, 3, 4])); // 4
// getLength(42); // Должна быть ошибка компиляции, у числа нет свойства length

Решение задачи 2

typescript
function getLength<T extends { length: number }>(entity: T): number {
  return entity.length;
}

Это подводит нас к очень важной теме, ограничениям дженериков (Generics Constraints), которую мы детально разберем в следующем уроке. Ключевое слово extends позволяет нам сказать: «Тип T может быть каким угодно, но он должен иметь как минимум свойство length типа number«.

Задача 3: Функция updateObject.

Напишите функцию updateObject, которая принимает три аргумента:

  1. Исходный объект типа T.

  2. Ключ типа K, который является ключом объекта T.

  3. Новое значение для этого ключа. Тип нового значения должен соответствовать типу T[K].

Функция должна возвращать новый объект с обновленным значением.

typescript
// Ваша реализация здесь...

// Проверка:
const user = { name: "Max", age: 30 };
const updatedUser = updateObject(user, "age", 31); // { name: "Max", age: 31 }
// updateObject(user, "age", "31"); // Ошибка: тип "string" не может быть присвоен типу "number"
// updateObject(user, "surname", "Ivanov"); // Ошибка: "surname" не существует в типе { name: string; age: number; }

Решение задачи 3

typescript
function updateObject<T, K extends keyof T>(obj: T, key: K, value: T[K]): T {
  return { ...obj, [key]: value };
}

Это уже более продвинутое использование! Здесь мы используем два параметра типа и сразу два мощных инструмента:

  1. K extends keyof T это ограничение говорит: «K может быть только одним из ключей (имен свойств) типа T«.

  2. T[K] это lookup type. Он означает «тип, который находится в свойстве K объекта типа T«. Например, если T это { name: string, age: number }, а K это "age", то T[K] будет number.

Таким образом, мы создали невероятно типобезопасную функцию для обновления объекта. TypeScript не даст нам ошибиться ни в имени ключа, ни в типе передаваемого значения.

Заключение

Generics это краеугольный камень всей системы типов в TS. Они позволяют создавать компоненты, которые не привязаны к одному конкретному типу данных, но остаются полностью типобезопасными.

Мы начали с осознания проблемы, выбор между гибкостью (any) и безопасностью (конкретные типы). Generics предложили нам элегантный выход из этой дилеммы, позволив передавать типы в качестве параметров.

Вы уже научились:

  • Объявлять generic-функции с помощью угловых скобок <T>.

  • Использовать параметр типа T для аргументов и возвращаемого значения.

  • Вызывать generic-функции как с явным указанием типа, так и полагаясь на вывод типов TypeScript.

  • Видеть практическую пользу дженериков на примере функций для работы с массивами и объектами.

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

Удачи в изучении и до встречи в следующем уроке!

Это урок из серии «TypeScript для начинающих».

Поделиться статьей:
Поддержать автора блога

Поддержка автора осуществляется с помощью специальной формы ниже, предоставленной сервисом «ЮMoney». Все платёжные операции выполняются на защищённой странице сервиса, что обеспечивает их корректность и полную безопасность.

Персональные рекомендации
Оставить комментарий