DevSurge 💦

TypeScript Generics

Cover Image for TypeScript Generics
Mark Nelyubin
Mark Nelyubin

Один из принципов написания хорошего кода — не повторяться. Для этого нужно создавать код, который можно использовать повторно. Одна из возможностей делать это с помощью TypeScript — Generics.

Расскажу что такое дженерики, зачем нужны, как использовать, рассмотрим примеры из жизни с HTTP-запросами и промисами.

Что такое Дженерики

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

В качестве примера возьмем функцию, которая возвращает значение своего аргумента:

const identity = (arg: number): number => arg;

В данном случае мы ограничены конкретным типом number. Что если мы хотим сделать эту функцию универсальной? Первым на ум приходит использование any.

const identity = (arg: any): any => arg;

Функция стала универсальной, но мы потеряли информацию о возвращаемом типе и создаем себе проблемы на будущее.

const identity = (arg: any): any => arg;
identity(10).toUpperCase(); // [ERR]: identity(...).toUpperCase is not a function 

Как сделать функцию универсальной и при этом зафиксировать тип полученного аргумента и возвращаемого значения? Для этого нужно использовать переменную типа (type variable). Переменная типа, в отличие от обычной, хранит в себе не конкретное значение, а информацию о типе.

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

Мы добавили переменную Type к нашей функции. В качестве значения она хранит информацию о типе. Мы используем эту переменную для указания типа параметра функции и типа возвращаемого значения.

Проверим, предупредит ли нас компилятор об ошибке:

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

identity(10).toUpperCase(); // Property 'toUpperCase' does not exist on type '10'.(2339)

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

Если вы хотите использовать в качестве дженерика стрелочную функцию вместо "традиционной", вот варианты, как ее типизировать:

const identity = <T>(arg: T): T => arg;

// рабочие способы для использования с JSX:
const identity = <T extends unknown>(arg: T) => arg;
const identity = < T extends {} >(arg: T): T => arg;
const identity = < T, >(arg: T): T => arg;

Ограничения дженериков

Допустим, мы хотим использовать методы массива в теле функции:

function reverse<Type>(arg: Type): Type {
  const reversed = arg.reverse(); // Property 'reverse' does not exist on type 'Type'.
  return reversed;
}

TS будет ругаться, т.к. мы не указали в явном виде, что передаем массив, для которого работает этот метод. Мы можем передать number и получить ошибку, поэтому TS страхует нас. Чтобы избавиться от ошибки укажем, что ожидаем массив в качестве аргумента и возвращаемого значения:

function reverse<Type>(arg: Type[]): Type[] {
  const reversed = arg.reverse();
  return reversed;
}

console.log(reverse([1,2,3])); // [3, 2, 1] 

Другой пример, когда мы заранее понимаем, с какими типами данных будет работать функция. Пусть это будут все встроенные объекты, обладающие свойством length:

function loggingIdentity<Type>(arg: Type): Type {
  console.log(arg.length);
 // Property 'length' does not exist on type 'Type'.
  return arg;
}

Укажем, что переменная типа является продолжением сущности, обладающей нужным нам свойством:

interface Lengthwise {
  length: number;
}
 
function loggingIdentity<Type extends Lengthwise>(arg: Type): Type {
  console.log(arg.length); // Now we know it has a .length property, so no more error
  return arg;
}

Теперь мы получим ошибку при передаче в качестве аргумента некорректного типа (не обладающего свойством length), а с целевыми типами сможем комфортно работать, не теряя информацию о типе:

// Пример с ошибкой:
loggingIdentity(true); // Argument of type 'boolean' is not assignable to parameter of type 'Lengthwise'.

// Примеры с типами данных, имеющими свойство length:
loggingIdentity('true'); // 4
loggingIdentity([true]); // 1
loggingIdentity({val: true, length: 10}); // 10
function logArgumentsLength(){
    loggingIdentity(arguments) // 4
}
logArgumentsLength('1','2','3','5')

Можем создать параметр типа, который зависит от другого параметра типа. К примеру, мы хотим написать функцию, которая принимает объект и ключ, а возвращает значение по этому ключу.

function getProperty<Type>(obj: Type, key: string) {
  return obj[key]; // Element implicitly has an 'any' type because expression of type 'string' can't be used to index type 'unknown'.
}
const user = {name: 'Mark', age: 31}
getProperty(user, 'age');

В данном случае тип "строка" не подойдет, так как мы потенциально можем передать любое значение, например getProperty(user, 'phone') и получить undefined.

Вот как мы можем решить эту задачу:

function getProperty<Type, Key extends keyof Type>(obj: Type, key: Key) {
  return obj[key];
}
const user = {name: 'Mark', age: 31}
getProperty(user, 'age'); // 31
getProperty(user, 'email'); // Argument of type '"email"' is not assignable to parameter of type '"name" | "age"'.
  • Создать переменную типа Key и указать в качестве значения типа параметра key
  • Key extends keyof Type означает, что переменная типа Key должна быть ключом объекта Type.
  • keyof возвращает объединение всех возможных вариантов ключей Type. В данном случае — 'name', 'age'

Типизация дженериков

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

Допустим, мы хотим сохранить в переменную экземпляр функции:

const identity = <Type>(arg:Type):Type=>arg;
const copyIdentity = identity;

Можем в явном виде указать, что эта переменная содержит в себе дженерик-функцию:

const copyIdentity: <Type>(arg:Type) => Type = identity;

Можем вынести это описание в интерфейс, чтобы переиспользовать в будущем. Интерфейс также позволяет использовать переменную типа:

interface GenericIdentityFn<Type> {
    (arg:Type): Type;
}

const identity = <Type>(arg:Type):Type=>arg;

const numberIdentity:GenericIdentityFn<number> = identity;
numberIdentity('peter'); // Argument of type 'string' is not assignable to parameter of type 'number'.(2345)
  • Интерфейс описывает функцию, которая принимает аргумент определенного типа и возращает значение того же типа
  • Вместо конкретного типа мы использовали переменную типа Type
  • При создании экземпляра функции мы указали этот интерфейс и передали number в качестве значения переменной типа.

Таким образом, мы создали не-дженерик экземпляр numberIdentity, который является частью дженерик типа. При использовании интерфейса GenericIdentityFn мы явно указываем аргумент типа.

Примеры, когда пригодится Generic функция

HTTP-запросы

Когда мы делаем http-запрос, обычно описываем интерфейс с данными, которые ожидаем от бэка и пишем функцию, которая возвращает промис. Чтобы указать, какой тип данных будет содержать промис, нам пригодится переменная типа. Если для запросов используется библиотека axios, функция будет выглядеть так:

import axios, { AxiosError, AxiosResponse } from "axios";

interface Todo {
  id: number;
  title: string;
  completed: boolean;
}

const fetchTodo = async (id: number): Promise<Todo> => {
  try {
    const response: AxiosResponse<Todo> = await axios.get(
      `https://jsonplaceholder.typicode.com/todos/${id}`
    );
    console.log(response.data);
    return response.data;
  } catch (error) {
    if (axios.isAxiosError(error)) {
      const axiosError = error as AxiosError;
      console.log(`Axios error: ${axiosError.message}`);
      if (axiosError.response) {
        console.log(`Status code: ${axiosError.response.status}`);
        console.log(`Response data: ${axiosError.response.data}`);
      }
    } else {
      console.log("unexpected error: ", error);
    }
    throw error;
  }
};

fetchTodo(1);

В данном случае, используем интерфейс AxiosResponse и передаем интерфейс данных с помощью переменной типа Todo.

Вот так будет выглядеть POST-запрос:

interface CreatePost {
  title: string;
  body: string;
  userId: number;
}

const createPost = async (): Promise<{id: number} extends Todo>  => {
  try {
    const response: AxiosResponse<CreatePost> = await axios.post<CreatePost>(
      "https://jsonplaceholder.typicode.com/posts",
      {
        title: "foo",
        body: "bar",
        userId: 1
      }
    );

    console.log("result is: ", response.data);

    return response.data;
  } catch (error) {
    if (axios.isAxiosError(error)) {
      const axiosError = error as AxiosError;
      console.log(`Axios error: ${axiosError.message}`);
      if (axiosError.response) {
        console.log(`Status code: ${axiosError.response.status}`);
        console.log(`Response data: ${axiosError.response.data}`);
      }
    } else {
      console.log("unexpected error: ", error);
    }
    throw error;
  }
}

createPost();

Можете поиграться с этим кодом в песочнице.

Утилитарные функции

Допустим, у нас есть объект, из которого хотим достать ключи:

const person = { name: "Alice", age: 30, gender: "female" };
console.log(Object.keys(person)); // ["name", "age", "gender"]

Object.keys вернет массив строк. Вместо этого можем написать Generic-функцию, которую удобно переиспользовать:

// параметр типа ожидает объект, а функция ожидает вернуть массив ключей принятого объекта
const getObjectKeys = <T extends object>(obj: T): (keyof T)[] => {
  return Object.keys(obj) as (keyof T)[];
};

const person = { name: "Alice", age: 30, gender: "female" };
const keys = getObjectKeys(person); 
console.log(keys); // ["name", "age", "gender"]

Управление Промисами

Generics можно использовать для создания функций, которые работают с любым типом Promise, что позволяет обрабатывать асинхронные операции с безопасной типизацией.

Бывает такое, что при разработке интерфейсов еще не готов API-endpoint, нужно сделать мок ответа. Вот как это сделать без использования внешних библиотек с помощью Generic функции

interface Todo {
  id: number;
  title: string;
  completed: boolean;
}

const fetchTodo = (): Promise<Todo> => {
  const mockedData: Todo = {
    id: 1,
    title: "Mocked Todo",
    completed: false
  };

  return new Promise((resolve) => {
    setTimeout(() => resolve(mockedData), 1000); // Разрешает Promise с мок-данными через 1 секунду
  });
};

const main = async () => {
  const todo = await fetchTodo();
  console.log(todo); // { id: 1, title: 'Mocked Todo', completed: false }
};

main();

Итого

  • Generics — многократно используемый код, который может работать с различными типами
  • Type variable хранит информацию о типе
  • Простейший пример Generic функции: const identity = <Type>(arg:Type): Type => arg;
  • Можно создавать экземпляры с конкретным зафиксированным типом, в этом поможет интерфейс функции с переменными типов;
  • Знание Generics поможет работать с внешними библиотеками, писать утилитарные функции, http-запросы и мокать ответ с бэка.


Другие материалы

Cover Image for TypeScript Utility Types — вспомогательные типы и области их применения

TypeScript Utility Types — вспомогательные типы и области их применения

что такое Utility Types в TypeScript, расскажу про основные вспомогательные типы, и покажу, как применять их на реальных проектах.

Mark Nelyubin
Mark Nelyubin
Cover Image for Принципы SOLID с примерами на JS и Vue

Принципы SOLID с примерами на JS и Vue

Расскажу про принципы SOLID с актуальными примерами на JavaScript, Vue, React.

Mark Nelyubin
Mark Nelyubin