Generics в TypeScript: главная суперсила типизации
Представим, что мы пишем функцию:
function getFirstElement(arr: string[]) {
return arr[0];
}
Она работает со строками:
getFirstElement(["Vue", "React", "Angular"]);
Но что, если нам нужно получить первый элемент массива чисел?
Можно создать ещё одну функцию:
function getFirstNumber(arr: number[]) {
return arr[0];
}
А потом ещё для пользователей:
function getFirstUser(arr: User[]) {
return arr[0];
}
Проблема очевидна: логика абсолютно одинаковая.
Меняется только тип данных.
Именно здесь появляются generics.
Что такое generic?
Generic позволяет написать код, который работает с разными типами, но при этом не теряет информацию о типах.
Например:
function getFirstElement<T>(arr: T[]): T {
return arr[0];
}
T здесь означает:
«Какой-то тип, который мы узнаем позже».
Теперь функция работает со всем:
const firstString = getFirstElement(["Vue", "React"]); const firstNumber = getFirstElement([1, 2, 3]);
TypeScript сам понимает:
firstString // string firstNumber // number
Мы написали одну функцию, но сохранили точную информацию о типах.
Почему нельзя просто использовать any?
Можно написать:
function getFirstElement(arr: any[]): any {
return arr[0];
}
И вроде бы проблема решена.
Но теперь:
const first = getFirstElement(["Vue", "React"]);
имеет тип:
any
TypeScript больше не знает, что first является строкой.
Можно сделать что угодно:
first.nonExistentMethod();
И TypeScript не будет против.
Generic работает иначе:
function getFirstElement<T>(arr: T[]): T
Он говорит:
«Я не знаю заранее, какой тип ты передашь. Но я запомню его».
Если передали string, вернётся string.
Если number, вернётся number.
Если User, вернётся User.
Generics во frontend
Представим типичный запрос к API.
У нас есть пользователь:
type User = {
id: number;
name: string;
};
И функция:
async function fetchUser(): Promise<User> {
const response = await fetch("/api/user");
return response.json();
}
Всё хорошо.
Но теперь нам нужны:
/api/users /api/posts /api/products /api/comments
Мы можем написать отдельную функцию для каждого типа данных.
Но логика запроса одинаковая.
Поэтому:
async function request<T>(url: string): Promise<T> {
const response = await fetch(url);
return response.json();
}
Теперь:
const user = await request<User>("/api/user");
TypeScript понимает:
user.id; user.name;
А если мы сделаем:
user.price;
получим ошибку.
Для массива пользователей:
const users = await request<User[]>("/api/users");
Теперь TypeScript знает, что users это массив объектов User.
Generic с несколькими типами
Generics могут принимать несколько параметров.
Например:
function createPair<T, U>(first: T, second: U) {
return {
first,
second
};
}
Используем:
const pair = createPair("Vue", 3);
TypeScript выводит:
{
first: string;
second: number;
}
T и U это просто названия.
Можно написать:
function createPair<Name, Value>(
first: Name,
second: Value
) {
// ...
}
Но обычно используются:
T → Type K → Key V → Value
Например:
type Dictionary<K, V> = {
key: K;
value: V;
};
Ограничения generics
Иногда нам недостаточно сказать:
«Принимай любой тип».
Представим:
function getLength<T>(value: T) {
return value.length;
}
TypeScript скажет:
У типа T может не быть свойства length.
И действительно:
getLength("hello");
getLength([1, 2, 3]);
getLength(42);
У числа нет length.
Поэтому можно ограничить generic:
function getLength<T extends { length: number }>(
value: T
) {
return value.length;
}
Теперь TypeScript разрешает:
getLength("hello");
getLength([1, 2, 3]);
Но не разрешает:
getLength(42);
То есть:
<T>
означает:
любой тип.
А:
<T extends Something>
означает:
любой тип, который соответствует определённому условию.
Самая важная идея
Generics нужны не для того, чтобы сделать код сложнее.
Наоборот.
Они позволяют избежать такого:
getString(...) getNumber(...) getUser(...) getProduct(...)
И написать одну универсальную функцию:
get<T>(...)
Но при этом не потерять безопасность типов.
Если совсем коротко:
any ↓ «Я ничего не знаю о типе» generic ↓ «Я пока не знаю тип, но узнаю его при использовании и сохраню эту информацию»
Именно поэтому generics встречаются практически везде:
Promise<T> Array<T> Record<K, V> Map<K, V> Promise<ApiResponse<User>> ref<User | null>(null)
Как только вы начинаете замечать <T> в TypeScript-коде, оказывается, что половина магии библиотек построена именно на generics.
В следующем посте можно разобрать ещё более практичную тему: как типизировать API во frontend и почему response.json() as User не всегда хорошее решение.