Практика 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);

Проясним некоторые моменты:

  1. С помощью обобщения мы говорим компилятору, что функция ожидает на вход некую сущность, которая совместима с типом Record<string, any>. Тип Record — это встроенный утилитарный тип, с помощью которого можно описывать объекты. Первый аргумент — тип ключа, второй аргумент — тип значения. Запись Record<string, any> эквивалентна записи type SomeObject = { [key: string]: any }.
  2. Мы говорим, что функция в качестве значения возвращает список ключей переданного объекта. Т.к. мы уверены, что тип 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)
  }), {});
}
  1. С помощью первого параметра обобщения мы говорим, что функция будет работать с массивом сущностей, совместимых с объектом (говоря иначе — с массивом объектов).
  2. В качестве второго параметра мы принимаем тип значений нашего словаря и назначаем тип по умолчанию — тип значений передаваемых объектов.
  3. В качестве первого аргумента мы передаем функцию обратного вызова, которая принимает на вход объект массива и должна вернуть ключ для регистрации текущего объекта в словаре.
  4. В качестве второго аргумента мы передаем функцию обратного вызова, которая принимает на вход объект массива и должна вернуть значение в словаре для текущего объекта.
  5. Наша функция возвращает другую функцию, принимающую на вход, собственно, сам массив. Внутренняя функция будет иметь доступ к аргументам внешней функции благодаря замыканию. Такой прием очень часто используется в функциональном программировании и называется частичным применением.
  6. Мы типизируем значение, которое вернёт 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-приложении.