TypeScript Generics



Один из принципов написания хорошего кода — не повторяться. Для этого нужно создавать код, который можно использовать повторно. Одна из возможностей делать это с помощью 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-запросы и мокать ответ с бэка.