Typescript Generics

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

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

При этом написание типов бывает действительно утомительным, но Typescript предоставляет возможности ускорить и этот процесс. Здесь нам на помощь придут дженерики.

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

Generic в переводе с английского значит «универсальный», то есть дженерики дают нам возможность делать универсальные типы. К слову в Typescript есть ряд встроенных утилитарных типов (Utility Types), на примере которых можно понять принцип работы дженериков.

Для примера возьму один из моих любимых Utility Type — Pick. Довольно часто мне приходится прикидывать свойства к готовому UI компоненту из библиотеки от контроллера через компонент разметки (Layout). Вот упрощенный пример:

import { QuestionCircleOutlined } from "@ant-design/icons";
import { Button, Flex, Typography, Input, Space, ButtonProps } from "antd";
import { SearchProps } from "antd/es/input";

interface ILayout {
  buttonProps: Pick;
  inputProps: Pick;
  result: string | undefined;
}

export const Layout: React.FC = (props) => {
  const { buttonProps, inputProps, result } = props;
  return (
    
      
        
          Estimate your age based on your first name
        
        
        
          
            Your age:  
            {result ? result : }
          
          
        
      
    
  );
};

Вся магия происходит в строчке buttonProps: Pick; и inputProps: Pick;, где определяется, что тип buttonProps и inputProps соответствует типам ButtonProps и SearchProps, но не полностью. Из их типов с помощью Pick выбираем только те свойства, что будем использовать.

Чтобы развеять все вопросы запишу проще:

Запись buttonProps: Pick; эквивалентна следующей:

buttonProps: {
  disabled?: boolean;
  onClick?: React.MouseEventHandler | undefined;
}

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

Если с Utility Types все понятно — берешь и используешь, то как писать свои универсальные типы? Давайте напишем свой Pick, чтобы разобраться в этом.

type CustomPick = {
  [Key in K]: T[Key];
};

Универсальность достигается за счет того, что дженерик-типы принимают в себя другие типы, как аргументы, а также с помощью ряда ключевых слов могут манипулировать ими. В данном примере дженерик-тип CustomPick принимает два аргумента T и K. Тип T наследует типу object, а тип K наследует значениям ключей объекта типа T. Затем идет выражение дженерик-типа CustomPick, используя эти аргументы. CustomPick — это объект, в котором ключом может быть только ключ, принадлежащий Union-типу ключей объекта T, то есть запись Key in K равно Key in keyof T, а если бы писали прямо по типу ButtonProps, то равно Key in ‘disabled’ | ‘onClick’ | …другие ключи типа ButtonProps. А значение этого ключа мы предоставляем с помощью записи T[Key].

В этом примере мы увидели такие ключевые слова, как extends, in, keyof. Их на самом деле намного больше, но для того, чтобы понять всю силу дженериков, нам понадобиться еще только одно — infer.

Ключевое слово infer от inference, что переводится как «вывод» — это одно из тех ключевых слов, о котором спрашивают на собеседованиях, так как понимание принципа работы этого ключевого слова может отразить насколько хорошо вы знаете Typescript в целом. Это, так сказать, advanced уровень.

Так что же делает это ключевое слово? Чтобы открыть принцип его работы снова возьму пример из практики. Для работы с API обычно генерируют классы с методами, а также пишут или используют готовые решения — функции-хелперы или хуки для централизованной работы с такими классами, чтобы иметь возможность, например формировать логи в случае ошибки или проверять run time типы. Сейчас вы увидите пример хука, где во всю используется сила ключевого слова infer и утилитарного типа ReturnType для работы с такого рода классами. Только не пугайтесь, все не так сложно, как может показаться:

import * as React from "react";

import { ApiConfig, HttpResponse, RequestParams } from "../api/http-client";

type ExtractHttpResponse =
  Type extends Promise
    ? X extends HttpResponse
      ? XX
      : never
    : never;

type Action = {
  type: "FETCH_INIT" | "FETCH_SUCCESS" | "FETCH_FAILURE" | "RESET";
  payload?: { data?: Data; error?: Error };
};

type State = {
  isLoading: boolean;
  isError: boolean;
  data: Data | void;
  error: Error | void;
};

const getDataFetchReducer =
  () =>
  (state: State, action: Action): State => {
    switch (action.type) {
      case "FETCH_INIT":
        return {
          ...state,
          isLoading: true,
          isError: false,
        };
      case "FETCH_SUCCESS":
        return {
          isLoading: false,
          isError: false,
          data: action.payload?.data,
          error: void 0,
        };
      case "FETCH_FAILURE":
        return {
          ...state,
          isLoading: false,
          isError: true,
          error: action.payload?.error,
        };
      case "RESET":
        return {
          data: void 0,
          isLoading: false,
          isError: false,
          error: void 0,
        };
      default:
        return {
          ...state,
        };
    }
  };

export function useApi<
  ApiGetter extends (
    config: ApiConfig,
    params: RequestParams,
  ) => Record<
    keyof ReturnType,
    ReturnType[keyof ReturnType]
  >,
  Method extends keyof ReturnType,
>(
  api: ApiGetter,
  method: Method,
  initialData?: ExtractHttpResponse[Method]>>,
  onSuccess?: (
    response: ExtractHttpResponse[Method]>>,
  ) => void,
  onError?: (error: Error) => void,
  config?: ApiConfig,
  params?: RequestParams,
): [
  callApi: ($args: Parameters[Method]>) => void,
  state: State[Method]>>>,
  reset: () => void,
  responseHeaders:
    | HttpResponse<
        ExtractHttpResponse[Method]>>,
        Error
      >["headers"]
    | null,
] {
  const [args, setArgs] = React.useState[Method]
  > | null>(null);
  const [state, dispatch] = React.useReducer(
    getDataFetchReducer<
      ExtractHttpResponse[Method]>>
    >(),
    {
      isLoading: false,
      isError: false,
      data: initialData,
      error: void 0,
    },
  );
  const [responseHeaders, setResponseHeaders] = React.useState<
    | HttpResponse<
        ExtractHttpResponse[Method]>>,
        Error
      >["headers"]
    | null
  >(null);

  const callApi = React.useCallback(
    ($args: Parameters[Method]>) => {
      setArgs($args);
    },
    [],
  );

  const reset = React.useCallback(() => {
    dispatch({ type: "RESET" });
    setResponseHeaders(null);
  }, []);

  React.useEffect(() => {
    let didCancel = false;
    const fetchData = async () => {
      if (args) {
        dispatch({ type: "FETCH_INIT" });
        try {
          const result = await api(config ?? {}, params ?? {})[method](
            ...(args as Array),
          );

          if (!didCancel) {
            dispatch({ type: "FETCH_SUCCESS", payload: { data: result.data } });
            onSuccess && onSuccess(result.data);
            const headersKey = "headers";
            setResponseHeaders(result[headersKey]);
          }
        } catch (error) {
          if (!didCancel) {
            dispatch({
              type: "FETCH_FAILURE",
              payload: { error: error as Error },
            });
            onError && onError(error as Error);
          }
        }
      }
    };

    fetchData();

    return () => {
      didCancel = true;
    };
  }, [args]); // eslint-disable-line react-hooks/exhaustive-deps

  return [callApi, state, reset, responseHeaders];
}

Я не буду расписывать все, что здесь происходит, так как в этом случае статья будет просто огромной. Если будет интересно, как это все работает, то заходите в песочницу. Ограничусь кратким описанием.

Этот хук создан для работы с классами API, при чем неважно с какими, главное чтобы они удовлетворяли требованиям типизации, а именно:

  1. Первым аргументом должна быть функция, которая извлекает методы из класса API с сигнатурой:

(config: ApiConfig, params: RequestParams) => Record<
  keyof ReturnType,
  ReturnType[keyof ReturnType]
>

Вот пример такой функции:

export const getAgifyApiMethods = (
  config: ApiConfig = {},
  params: RequestParams = {},
) => {
  const baseUrl = "https://api.agify.io";
  return {
    getAge: (query: IAgeQuery) =>
      new ApiClass({ ...config, baseUrl }).getAge(query, params),
  };
};

Она принимает конфигурацию http client«а и параметры запроса, а возвращает объект с методами, которые уже принимают тело запроса или query-параметры и создают instance класса, передавая конфигурацию, а затем вызывает нужный метод, передавая тело запроса, query-параметры и параметры самого запроса.

  1. Вторым аргументом идет нужный метод:

Method extends keyof ReturnType

Здесь уже знакомая нам сигнатура extends keyof, с помощью который мы получаем ключи объекта и тот самый ReturnType. Этого нам достаточно для разбора, остальные параметры можете при желании разобрать самостоятельно.

Утилитарный тип ReturnType возвращает тип того, что возвращает функция. Давайте взглянем на его реализацию:

type ReturnType = T extends (...args: any[]) => infer R ? R : any;

Ключевое слово infer работает только в условных типах — это тоже важная часть Typescript, которую также бы хорошо изучить для понимания работы дженериков. Постараюсь объяснить кратко. Условные типы (Conditional Types) по сути работают также, как тернарный оператор в Javascript, только не со значениями, а с типами. В качестве условия здесь выступает принадлежность к определенному типу, в случае с ReturnType проверяется, что тип T наследуют интерфейсу функции:

T extends (...args: any[]) => infer R

Сигнатура infer R извлекает то, что вернет подставленная в ReturnType функция, например:

const concat = (a: string, b: string) => a + b:

type Concated = ReturnType;
// => string

А если передать в этот тип не функцию, то условие не выполнится и вернется any:

type Concated = ReturnType;
// => any

Чтобы ощутить полезность таких возможностей, взглянем на то как используется хук useApi:

  const [getAge, { data, isLoading }, reset] = useApi(
    getAgifyApiMethods,
    "getAge",
  );

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

Сигнатура getAge

Сигнатура getAge

Ошибка при передаче метода, который не принадлежит переданному классу API

Ошибка при передаче метода, который не принадлежит переданному классу API

Сигнатура ответа

Сигнатура ответа

Там же в песочнице вы можете посмотреть и другие дженерик-типы, которые построены на infer и не только.

Спасибо за внимание! Если вам понравилось то, как я пишу статьи, подписывайтесь на мой телеграм-канал, где вы сможете участвовать в выборе тем.

© Habrahabr.ru