Зачем нужны generics в TypeScript?
Generics в TypeScript нужны в тех случаях, когда одна функция или структура должна работать с разными типами, сохраняя при этом информацию о конкретном типе.
Например, для массива строк можно написать string[], для пользователей - User[]. Если функция принимает только один из этих типов, проблем нет. Но когда одна и та же функция должна работать с разными типами, приходится либо дублировать код, либо использовать any.
Например, есть такая функция:
//ts
function getFirst(items: string[]) {
return items[0];
}
Через некоторое время понадобится то же самое для чисел:
//ts
function getFirst(items: number[]) {
return items[0];
}Сами функции практически одинаковые. Различается только тип.
Первое желание - сделать так:
//ts
function getFirst(items: any[]) {
return items[0];
}Работать будет. Но вместе с any исчезает часть пользы TypeScript.
Вместо этого можно оставить одну функцию:
//ts
function getFirst<T>(items: T[]): T {
return items[0];
}Вот это и есть generic.
Что такое T
Буква T здесь не означает какой-то конкретный тип. Это скорее пустое место, которое TypeScript заполнит, когда функция будет вызвана.
Например:
//ts
const name = getFirst(["Stanly", "Alex"]);В этот момент T становится string.
Если написать:
//ts
const count = getFirst([10, 20, 30]);T уже будет number.
Причём у функции не появилось двух версий. Она осталась одной:
//ts
function getFirst<T>(items: T[]): TЭто особенно удобно, когда заранее неизвестно, с каким типом она будет работать.
Например:
//ts
const user = getFirst([
{ id: 1, name: "Stanly" },
{ id: 2, name: "Alex" }
]);TypeScript увидит структуру объектов и сохранит её в результате. У user будут id и name, а не какой-нибудь абстрактный any.
И здесь находится главное отличие generic от универсальности через any.
any делает код свободнее, но типы приходится отпускать
Возьмём ещё более простой пример:
//ts
function identity(value: any): any {
return value;
}Функция возвращает то, что получила.
С виду ничего страшного. Но TypeScript теперь практически не знает, что происходит с аргументом.
Можно написать тот же код через generic:
//ts
function identity<T>(value: T): T {
return value;
}Если передать строку, результат будет строкой. Если объект - объектом с его структурой.
Это небольшая разница в коде, но довольно большая разница в поведении редактора и проверки типов.
Например:
//ts
const user = identity({
id: 1,
name: "Stanly"
});
user.name;Здесь TypeScript знает, что name существует.
При работе с any эту информацию можно довольно быстро потерять.
Поэтому generic - это не просто способ сказать «сюда можно передавать что угодно». Скорее наоборот: тип пока неизвестен, но мы хотим сохранить его настолько точно, насколько это возможно.
Где это начинает приносить пользу
Хороший пример - ответы API.
Предположим, сервер возвращает примерно такую структуру:
//ts
{
data: ...,
status: 200
}Поле data сегодня содержит пользователя:
//ts
{
id: 1,
name: "Stanly"
}А завтра статью:
//ts
{
id: 25,
title: "Generics в TypeScript"
}Оболочка ответа при этом не меняется.
Можно описать её один раз:
//ts
interface ApiResponse<T> {
data: T;
status: number;
}Теперь T представляет именно те данные, которые находятся внутри data.
Например:
//ts
const userResponse: ApiResponse<User> = {
data: {
id: 1,
name: "Stanly"
},
status: 200
};Для статьи используется тот же интерфейс:
//ts
const articleResponse: ApiResponse<Article> = {
data: {
id: 25,
title: "Generics в TypeScript"
},
status: 200
};Без generic пришлось бы описывать несколько почти одинаковых интерфейсов. Сам по себе такой код не является катастрофой, но в большом приложении подобных конструкций становится много.
Generic позволяет оставить общую часть общей, а изменяющийся тип передавать отдельно.
Иногда одного T недостаточно
Есть интересный момент, который становится заметен после первых примеров.
Если написать:
//ts
function getLength<T>(value: T) {
return value.length;
}TypeScript будет ругаться.
Причина простая: T может оказаться чем угодно. Например, числом:
//ts
getLength(100);У числа нет length.
Нам нужно сообщить компилятору, что подходит не любой тип, а только такой, у которого есть нужное свойство.
Для этого generic можно ограничить:
//ts
function getLength<T extends { length: number }>(value: T) {
return value.length;
}Теперь подходят строки:
//ts
getLength("hello");и массивы:
//ts
getLength([1, 2, 3]);А число уже не подходит.
Здесь extends задаёт не наследование в привычном смысле, а условие для T. Мы говорим TypeScript: конкретный тип может быть любым, но определённому требованию он должен соответствовать.
На практике это довольно полезная вещь. Универсальная функция не обязана принимать вообще всё.
## Ещё интереснее становится, когда тип влияет на результат
Допустим, есть функция для извлечения данных из API-ответа:
//ts
function getData<T>(response: ApiResponse<T>): T {
return response.data;
}Если передать:
//ts
const user = getData(userResponse);TypeScript понимает, что здесь результат - User.
Если передать ответ статьи:
//ts
const article = getData(articleResponse);результатом становится Article.
То есть generic здесь нужен не только для того, чтобы разрешить разные входные значения. Он сохраняет связь между входом и выходом.
Это одна из вещей, ради которых generics действительно стоит запомнить.
Представьте функцию, которая принимает некоторый тип данных и должна вернуть данные того же типа. Или функцию, которая получает объект и ключ этого объекта, а затем возвращает значение по этому ключу. Или компонент, которому передают массив пользователей, товаров или сообщений.
Во всех этих случаях типы связаны между собой.
И если эту связь потерять, TypeScript уже не сможет нормально помогать.
Generics в React
В React похожая ситуация возникает с переиспользуемыми компонентами.
Допустим, нужен универсальный список. Он получает массив и функцию для отображения элемента:
//tsx
type ListProps<T> = {
items: T[];
renderItem: (item: T) => React.ReactNode;
};
function List<T>({ items, renderItem }: ListProps<T>) {
return (
<div>
{items.map(renderItem)}
</div>
);
}Теперь компонент можно использовать для пользователей:
//tsx
<List
items={users}
renderItem={(user) => (
<div>{user.name}</div>
)}
/>А можно передать товары:
//tsx
<List
items={products}
renderItem={(product) => (
<div>{product.title}</div>
)}
/>Здесь особенно хорошо видно, зачем всё это нужно.
Сам List не знает заранее, с какими объектами будет работать. Но это не означает, что он ничего о них не знает. Когда передаётся users, TypeScript связывает T с типом пользователя. Когда передаётся products, T становится типом товара.
Если внутри второго renderItem попытаться обратиться к свойству, которого у товара нет, редактор сможет это подсветить.
С any пришлось бы надеяться в основном на себя.
Но generics не надо вставлять везде
Есть другая крайность.
После знакомства с generic легко решить, что хороший TypeScript-код должен выглядеть примерно так:
//ts
function doSomething<T, K extends keyof T>(...)чем сложнее, тем лучше.
На практике это не работает.
Если функция предназначена только для пользователя:
//ts
function getUserName(user: User): string {
return user.name;
}generic здесь ничего не улучшит.
Не нужно делать:
//ts
function getUserName<T>(user: T): string {
// ...
}Только ради того, чтобы в коде появился <T>.
Такая функция всё равно ожидает конкретную структуру пользователя. Значит, её и стоит описать конкретным типом.
Generics становятся полезными тогда, когда действительно есть общая логика, а конкретный тип может меняться.
Как понять, что здесь нужен generic
На практике я бы смотрел не на сам синтаксис, а на код вокруг него.
Например, если появляются такие функции:
//ts
getFirstString()
getFirstNumber()
getFirstUser()и внутри у них практически одна и та же логика, это повод остановиться.
Или появляется желание написать:
//ts
function something(value: any) {
// ...
}только потому, что функция должна работать с разными типами.
Или есть одна структура:
//ts
ApiResponse<User>
ApiResponse<Article>
ApiResponse<Product>где меняется только содержимое data.
Во всех этих случаях generic может убрать повторение, не превращая типизацию в any.
Но если различия между двумя функциями не ограничиваются типом, насильно объединять их тоже не стоит. Универсальность сама по себе не является целью.
Generics нужны не ради <T>
Когда я впервые столкнулся с generics, конструкция вроде:
//ts
function something<T>(value: T): Tказалась отдельной сложностью TypeScript.На деле сложность здесь скорее в непривычной записи. Идея гораздо проще.
Есть код, который должен работать с разными типами. Конкретный тип заранее неизвестен. Но при выполнении операции связь с этим типом терять нельзя.
В таком случае generic позволяет сказать TypeScript: «я пока не знаю, какой здесь будет тип, но когда узнаешь - сохрани его».
Отсюда и получается практическая польза.
Не приходится писать одну и ту же функцию для string, number, User и Article. Не нужно ставить any только ради универсальности. А редактор продолжает понимать, какие данные проходят через код.
Поэтому хороший повод вспомнить про generics выглядит довольно буднично: вы видите одну и ту же логику, которая должна работать с разными типами, и не хотите ради этого терять типизацию.
В такой ситуации <T> уже перестаёт выглядеть как сложная конструкция. Это просто способ сохранить информацию о типе, который станет известен чуть позже.
Комментарии
Чтобы оставить комментарий, войдите в аккаунт.