Приветствую тебя коллега, на тридцать третьем уроке нашего большого путешествия по TypeScript. Сегодня мы разберем одну из самых мощных возможностей TypeScript, это Дженерики (Generics), а конкретнее, их применение в функциях и интерфейсах.
Этот инструмент кардинально изменит твой подход к созданию универсального и переиспользуемого кода. Если до этого мы писали функции для конкретных типов (например, number или string), то сегодня мы научимся писать функции-«шаблоны», которые будут работать с любыми типами, которые мы укажем. Поехали.
Зачем нужны дженерики?
Представь себе самую обычную функцию, которая возвращает первый элемент массива. Без дженериков нам пришлось бы писать отдельные версии для каждого типа.
// Плохой подход: дублирование кода function getFirstNumber(arr: number[]): number { return arr[0]; } function getFirstString(arr: string[]): string { return arr[0]; } function getFirstBoolean(arr: boolean[]): boolean { return arr[0]; } const num = getFirstNumber([1, 2, 3]); // OK, тип num - number const str = getFirstString(["a", "b", "c"]); // OK, тип str - string const bool = getFirstBoolean([true, false]); // OK, тип bool - boolean
Это ужасно неэффективно и нарушает принцип DRY (Don’t Repeat Yourself — не повторяйся). Мы по сути пишем одну и ту же функцию снова и снова, меняя лишь аннотации типов.
Можно попробовать использовать тип any:
// Опасно: потеря информации о типе function getFirstAny(arr: any[]): any { return arr[0]; } const num = getFirstAny([1, 2, 3]); // тип num - any! const str = getFirstAny(["a", "b", "c"]); // тип str - any!
Теперь у нас одна функция, но мы потеряли самую главную ценность TypeScript, информацию о типах. Компилятор теперь не знает, что num это число, а str строка. Это ведет к потенциальным ошибкам, которые мы не сможем отследить на этапе компиляции.
Вот здесь на сцену и выходят Дженерики. Они позволяют нам создать «шаблон» функции. Мы передаем в этот шаблон нужный тип как аргумент и функция «подстраивается» под него.
// Хороший подход: использование дженерика function getFirst<T>(arr: T[]): T { return arr[0]; } const num = getFirst<number>([1, 2, 3]); // тип num - number const str = getFirst<string>(["a", "b", "c"]); // тип str - string const bool = getFirst<boolean>([true, false]); // тип bool - boolean
Вуаля! Одна функция, полная типобезопасность. Компилятор теперь знает, что тип возвращаемого значения точно совпадает с типом элементов в массиве. Это и есть суть дженериков.
Синтаксис <T>: Объявляем параметр типа
Сердце любого дженерика это параметр типа. Его принято обозначать одной заглавной буквой. Чаще всего используют T (от слова Type), но ничто не мешает использовать U, V, ElementType, KeyType, главное чтобы это было понятно тебе и твоим коллегам.
Параметр типа указывается в угловых скобках < > сразу после имени функции, интерфейса или класса.
function identity<T>(arg: T): T { return arg; }
Давай разберем эту простейшую функцию identity (она просто возвращает свой аргумент) по косточкам:
-
<T>: Это объявление параметра типа. Мы говорим компилятору: «Слушай, здесь будет использоваться некий тип, я пока не знаю какой, но я назову егоT». -
(arg: T): Здесь мы используемTдля аннотации типа аргумента. То есть тип аргументаargбудет именно тем типом, который мы передадим при вызове функции. -
: T: Здесь мы говорим, что возвращаемое значение функции будет тоже иметь типT.
Когда мы вызываем такую функцию, мы можем (а часто и не обязаны) явно указать тип в угловых скобках:
// Явное указание типа let output1 = identity<string>("myString"); // Тип output1 будет 'string' // Вывод типа (type inference) let output2 = identity("myString"); // Тип output2 также будет 'string'!
TypeScript невероятно умен. Во втором случае он посмотрел на переданный аргумент "myString", понял, что его тип string и автоматически подставил его вместо T для всего вызова функции. Это называется выводом типов (type inference) и он делает наш код чище и легче для чтения.
Создание универсальных функций с дженериками
Теперь давай рассмотрим более практичные и полезные примеры универсальных функций.
Пример 1: Функция для слияния двух объектов
Допустим, мы хотим объединить два объекта в один новый. Объекты могут быть любого типа.
function merge<T, U>(obj1: T, obj2: U): T & U { return { ...obj1, ...obj2 }; } const merged = merge( { name: "Max", age: 30 }, { job: "Developer", hobbies: ["Programming", "Gaming"] } ); /* Тип merged выведен автоматически как: { name: string; age: number; job: string; hobbies: string[]; } */ console.log(merged); // { name: "Max", age: 30, job: "Developer", hobbies: ["Programming", "Gaming"] }
Здесь мы используем два параметра типа: T (для первого объекта) и U (для второго). Возвращаем мы тип T & U (пересечение типов), что означает объект, который обладает всеми свойствами и из T и из U.
Пример 2: Улучшенная функция getFirst с проверкой на пустой массив
Давай улучшим нашу первоначальную функцию, чтобы она возвращала undefined, если массив пустой.
function getFirst<T>(arr: T[]): T | undefined { return arr.length > 0 ? arr[0] : undefined; } const firstNumber = getFirst([1, 2, 3]); // type: number | undefined const firstString = getFirst([]); // type: string | undefined (значение: undefined)
Теперь наша функция стала еще более надежной и безопасной.
Пример 3: Функция для получения длины аргумента, у которого есть свойство .length
Иногда мы хотим работать не с любым типом T, а с теми, которые обладают определенными характеристиками. Например, мы хотим написать функцию, которая принимает любой объект, у которого есть свойство length и возвращает это свойство.
Для этого мы используем ограничения (constraints) с помощью ключевого слова extends.
// Без ограничения - может быть ошибка function getLength<T>(arg: T): number { return arg.length; // Ошибка компиляции: Свойство 'length' не существует на типе 'T'. } // С ограничением: T должен быть типом, у которого есть свойство length типа number interface Lengthwise { length: number; } function getLength<T extends Lengthwise>(arg: T): number { return arg.length; // Теперь все OK! } getLength("hello"); // OK, у строки есть свойство length getLength([1, 2, 3]); // OK, у массива есть свойство length getLength({ length: 42, name: "Max" }); // OK, у объекта есть свойство length типа number getLength(42); // Ошибка: у числа нет свойства .length
Мы говорим: «Тип T должен расширять (extends) интерфейс Lengthwise». Это значит, что в качестве T можно подставить только тот тип, который имеет как минимум свойство length: number. Это мощный механизм, который позволяет нам гибко настраивать наши дженерики.
Создание универсальных интерфейсов
Дженерики не ограничиваются функциями. Мы можем создавать универсальные интерфейсы, которые являются шаблонами для описания структур данных.
Пример 1: Интерфейс для ответа API
Очень распространенный случай, это ответ от сервера. Часто он имеет стандартную обертку: { data: ..., status: ... }, где data это полезная нагрузка, которая может быть любого типа в зависимости от запроса (массив пользователей, токен аутентификации, информация о товаре и т.д.).
interface ApiResponse<T> { data: T; status: number; message?: string; // Необязательное поле } // Используем интерфейс для ответа с массивом пользователей interface User { id: number; name: string; } const usersResponse: ApiResponse<User[]> = { data: [{ id: 1, name: "Max" }, { id: 2, name: "Anna" }], status: 200 }; // Используем тот же интерфейс для ответа с одним товаром interface Product { sku: string; price: number; } const productResponse: ApiResponse<Product> = { data: { sku: "ABC123", price: 29.99 }, status: 200 }; // Обращаемся к данным с полной типобезопасностью console.log(usersResponse.data[0].name); // "Max", тип string console.log(productResponse.data.price); // 29.99, тип number
Один интерфейс ApiResponse описал бесконечное количество возможных структур ответов от сервера. Это невероятно мощно и удобно.
Пример 2: Интерфейс для функции сравнения
Часто в алгоритмах (сортировка, поиск) нужна функция сравнения. Давай опишем ее с помощью дженерика.
interface Comparator<T> { (a: T, b: T): number; } // Функция сравнения для чисел const numberComparator: Comparator<number> = (a, b) => a - b; [10, 4, 2, 8].sort(numberComparator); // [2, 4, 8, 10] // Функция сравнения для объектов по свойству age interface Person { name: string; age: number; } const personAgeComparator: Comparator<Person> = (a, b) => a.age - b.age; const people: Person[] = [{ name: "Max", age: 30 }, { name: "Anna", age: 25 }]; people.sort(personAgeComparator); // [{ name: "Anna", age: 25 }, { name: "Max", age: 30 }]
Интерфейс Comparator<T> описывает любую функцию, которая принимает два аргумента одного типа T и возвращает число. Это прекрасный пример повторного использования кода на уровне типов.
Практические задачи для закрепления
Давай попробуем применить знания на практике. Попробуй решить эти задачи самостоятельно, прежде чем смотреть решение.
Задача 1: Функция toArray
Напиши универсальную функцию toArray, которая принимает один или несколько аргументов и возвращает массив этих аргументов.
// Пример вызова и ожидаемый результат: toArray(1, 2, 3); // [1, 2, 3] (тип number[]) toArray("a", "b"); // ["a", "b"] (тип string[]) toArray(5); // [5] (тип number[])
Подсказка: Вспомни о rest-параметрах. Тип параметра функции будет ...args: T[].
Решение задачи 1
function toArray<T>(...args: T[]): T[] { return args; } const numArr = toArray(1, 2, 3); // тип: number[] const strArr = toArray("a", "b"); // тип: string[] const singleNum = toArray(5); // тип: number[]
Задача 2: Функция findByKey
Напиши функцию findByKey, которая принимает массив объектов одного типа и ключ, по которому нужно найти объект со значением этого ключа равным заданному. Функция должна возвращать найденный объект или undefined.
interface Car { manufacturer: string; model: string; year: number; } const cars: Car[] = [ { manufacturer: "Toyota", model: "Camry", year: 2020 }, { manufacturer: "Honda", model: "Civic", year: 2021 }, { manufacturer: "Tesla", model: "Model 3", year: 2022 } ]; // Пример вызова: findByKey(cars, "model", "Camry"); // { manufacturer: "Toyota", model: "Camry", year: 2020 } findByKey(cars, "year", 2019); // undefined
Подсказка: Тебе понадобятся два параметра типа: один для типа объектов в массиве (T), другой для типа ключа (K). Используй ограничение K extends keyof T, чтобы гарантировать, что переданный ключ действительно существует в объекте типа T.
Решение задачи 2
function findByKey<T, K extends keyof T>(items: T[], key: K, value: T[K]): T | undefined { return items.find(item => item[key] === value); } const result = findByKey(cars, "model", "Camry"); // OK, тип Car | undefined const result2 = findByKey(cars, "year", 2019); // OK, undefined // Компилятор подсветит ошибку: const error = findByKey(cars, "color", "red"); // Ошибка: Аргумент типа '"color"' не может быть присвоен параметру типа '"manufacturer" | "model" | "year"'.
Обрати внимание, как keyof T и T[K] создают идеальную типобезопасность. Мы не можем ошибиться в имени ключа и мы не можем передать значение неправильного типа для выбранного ключа.
Задача 3: Интерфейс KeyValuePair
Создай универсальный интерфейс KeyValuePair, который описывает объект с двумя свойствами: key и value. Затем создай функцию createPair, которая возвращает такой объект.
// Пример вызова: const numberPair = createPair("age", 30); // { key: "age", value: 30 } (тип KeyValuePair<string, number>) const stringPair = createPair("city", "Moscow"); // { key: "city", value: "Moscow" } (тип KeyValuePair<string, string>)
Решение задачи 3
interface KeyValuePair<K, V> { key: K; value: V; } function createPair<K, V>(key: K, value: V): KeyValuePair<K, V> { return { key, value }; } const numberPair = createPair("age", 30); const stringPair = createPair("city", "Moscow");
Заключение по уроку 33
Ты научился:
-
Понимать проблему, которую решают дженерики: устранение дублирования кода и сохранение типобезопасности.
-
Объявлять параметры типа с помощью синтаксиса
<T>. -
Создавать универсальные функции, которые работают с любыми типами, а не только с
any. -
Применять ограничения с помощью
extends, чтобы сузить круг типов, с которыми может работать дженерик. -
Создавать универсальные интерфейсы для описания гибких структур данных, таких как ответы API или функции обратного вызова.
Дженерики это краеугольный камень профессиональной разработки на TypeScript. Они активно используются во всех крупных библиотеках и фреймворках (React, Vue, Angular, Express с TypeScript). Понимание этой концепции открывает тебе дорогу к написанию по-настоящему мощного, переиспользуемого и надежного кода.
Не переживай, если все еще чувствуешь небольшую неуверенность. Возвращайся к примерам, пробуй решать свои собственные задачи, экспериментировать. В следующих уроках мы будем использовать их постоянно, так что практики будет много.
Если ты хочешь погрузиться в тему еще глубже и узнать про дженерики в классах, про utility-типы (Partial, Pick, Omit), которые построены на дженериках, то жду тебя на следующих уроках нашего большого курса.
Полный курс с уроками по TypeScript для начинающих: https://max-gabov.ru/typescript-dlya-nachinaushih
Поддержка автора осуществляется с помощью специальной формы ниже, предоставленной сервисом «ЮMoney». Все платёжные операции выполняются на защищённой странице сервиса, что обеспечивает их корректность и полную безопасность.


