Практика TypeScript. Generics.
Моя философия — писать так, чтобы компилятор и IDE помогали в разработке
Я часто вижу, что разработчики используют TypeScript на достаточно примитивном уровне: «вот мы типизируем строки, вот мы типизируем объекты, вот мы типизируем пропсы и стейт компонента». К тому же, многие пишут на TypeScript так, чтобы компилятор не ругался, а моя философия — писать так, чтобы компилятор и IDE помогали в разработке. Я хочу поделиться своим опытом использования TypeScript на практике в формате нескольких статей. Мы рассмотрим дженерики (обобщения), условные типы, будем учиться выводить сложные типы, а также применим эти знания к разработке на React.
Для тех, кто впервые столкнулся с TypeScript, дженерики (обобщения) — одна из самых трудных тем. Обобщение — это параметризованный тип, который позволяет объявлять параметры типа, являющиеся временной заменой конкретных типов, конкретизация которых будет выполнена в момент создания экземпляра.
Напишем простую функцию, которая возвращает перевернутый массив. Нам неизвестен заранее тип элементов массива и мы хотим, чтобы функция работала с любым типом. Поэтому мы заменяем тип параметром T
.
function reverse<T>(arr: T[]): T[] {
return [...arr.reverse()];
}
const reversed = reverse(['a', 'b', 'c']);
const reversed2 = reverse([1, 2, 3]);
Теперь TypeScript сможет корректно вывести тип переменной reversed
.
Возьмем пример посложнее. Напишем типизированную обертку над Object.keys()
.
const getObjectKeys = <T extends /*1*/Record<string, any>>(obj: T) => Object.keys(obj) as Array</*2*/keyof T>;
const obj = {
name: 'Ivan',
age: 25
};
const keys = getObjectKeys(obj);
Проясним некоторые моменты:
- С помощью обобщения мы говорим компилятору, что функция ожидает на вход некую сущность, которая совместима с типом
Record<string, any>
. Тип Record — это встроенный утилитарный тип, с помощью которого можно описывать объекты. Первый аргумент — тип ключа, второй аргумент — тип значения. ЗаписьRecord<string, any>
эквивалентна записиtype SomeObject = { [key: string]: any }
. - Мы говорим, что функция в качестве значения возвращает список ключей переданного объекта. Т.к. мы уверены, что тип
T
будет совместим с объектом, мы можем запросить ключи этого объекта с помощьюkeyof
.
Напишем еще одну функцию. Представим, что мы с сервера получаем список пользователей в виде массива объектов, где каждый объект описывает одного пользователя. Вам нужно сформировать из этого массива словарь (например, где ключом будет id пользователя, а значением будет его возраст). Задача легко решается с помощью reduce
. Но операций по превращению из массива в словарь может быть много. Поэтому мы захотели написать абстрактную функцию для таких превращений.
interface IUser {
id: string;
name: string;
age: number;
}
interface IResponse {
data: IUser[];
}
const getUsers = (): Promise<IResponse> => Promise.resolve({
data: [
{
id: '0001',
name: 'Ivan',
age: 30,
},
{
id: '0002',
name: 'Petr',
age: 25,
},
]
});
function createDictionaryFromArray<T extends Record<string, any>, V = T[keyof T]>(getKey: (el: T) => string, getValue: (el: T) => V) {
return (arr: T[]) => arr.reduce<Record<string, V>>((acc, el) => ({
...acc,
[getKey(el)]: getValue(el)
}), {});
}
getUsers()
.then(res => res.data)
.then(createDictionaryFromArray(el => el.id, el => el.age))
.then(usersAge => console.log(usersAge));
Давайте разберем, что тут происходит.
function createDictionaryFromArray</*1*/T extends Record<string, any>, /*2*/V = T[keyof T]>(/*3*/getKey: (el: T) => string, /*4*/getValue: (el: T) => V) {
return /*5*/(arr: T[]) => arr.reduce</*6*/Record<string, V>>((acc, el) => ({
...acc,
[getKey(el)]: getValue(el)
}), {});
}
- С помощью первого параметра обобщения мы говорим, что функция будет работать с массивом сущностей, совместимых с объектом (говоря иначе — с массивом объектов).
- В качестве второго параметра мы принимаем тип значений нашего словаря и назначаем тип по умолчанию — тип значений передаваемых объектов.
- В качестве первого аргумента мы передаем функцию обратного вызова, которая принимает на вход объект массива и должна вернуть ключ для регистрации текущего объекта в словаре.
- В качестве второго аргумента мы передаем функцию обратного вызова, которая принимает на вход объект массива и должна вернуть значение в словаре для текущего объекта.
- Наша функция возвращает другую функцию, принимающую на вход, собственно, сам массив. Внутренняя функция будет иметь доступ к аргументам внешней функции благодаря замыканию. Такой прием очень часто используется в функциональном программировании и называется частичным применением.
- Мы типизируем значение, которое вернёт reduce.
Обратите внимание: при вызове функции мы не передаем явно параметр нашего обобщения. Все обязательные параметры обобщения используются в параметрах функции и в этом случае TypeScript выведет типы сам. В данном случае TypeScript без проблем выведет тип, т.к. он знает тип значения, которое возвращает функция getUsers
.
А что если мы хотим в качестве значения в словаре хранить сам объект? Тогда нам нужно явно передать оба параметра в обобщение как это сделано ниже.
getUsers()
.then(res => res.data)
.then(createDictionaryFromArray<IUser, IUser>(el => el.id, el => el))
.then(usersAge => console.log(usersAge));
Какой профит дала нам типизация этой функции? Во-первых мы получили правильный тип переменной usersAge
. Во-вторых мы получили актуальные подсказки в IDE при написании функций getKey
и getValue
. Это именно та помощь TypeScript о которой я говорил в начале.
А теперь типизируем целый класс. Напишем класс-обертку над localStorage.
interface IPerson {
login: string;
name: string;
email: string;
}
class BrowserStorage<T extends Record<string, any>> {
constructor(private _id: string, private _storage: Storage) {}
get(key: keyof T): T[keyof T] {
const data = this.load();
return data[key];
}
set(key: keyof T, value: T[keyof T]) {
const data = this.load();
data[key] = value;
this.save(data);
}
private load(): T {
try {
const data = this._storage.getItem(this._id);
return data ? JSON.parse(data) : {};
} catch {
return {} as T;
}
}
private save(data: T) {
try {
this._storage.setItem(this._id, JSON.stringify(data));
} catch {}
}
}
const storage = new BrowserStorage<IPerson>("example", localStorage);
storage.set("login", "ivanov");
storage.set("name", "Ivanov Ivan");
storage.set("email", "ivan@ivanov.ru");
console.log(storage.get("login"));
На сегодня все. Надеюсь, что данная статья помогла вам лучше понять обобщения в TypeScript.
Все примеры я выложил на stackblitz.
В следующей статье мы поговорим про утилитарные типы, или как я их называю — «типо функции». Мы разберем встроенные утилитарные типы, а также напишем свои и используем их в нашем react-приложении.