Зачем нужны generics в TypeScript?

mr. Cooper • 1 час назад • Веб-разработка
Зачем нужны 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> уже перестаёт выглядеть как сложная конструкция. Это просто способ сохранить информацию о типе, который станет известен чуть позже.

Комментарии

Пока нет комментариев. Будьте первым, кто напишет.

Чтобы оставить комментарий, войдите в аккаунт.

Похожие статьи