Pull to refresh

Typescript Generics

Level of difficultyHard
Reading time8 min
Views6.8K

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<ButtonProps, "disabled" | "onClick">;
  inputProps: Pick<SearchProps, "loading" | "onChange" | "onSearch" | "value">;
  result: string | undefined;
}

export const Layout: React.FC<ILayout> = (props) => {
  const { buttonProps, inputProps, result } = props;
  return (
    <Flex
      style={{ height: "100%" }}
      align="center"
      justify="center"
      vertical={true}
    >
      <Space direction="vertical">
        <Typography.Title level={2}>
          Estimate your age based on your first name
        </Typography.Title>
        <Input.Search {...inputProps} placeholder="Enter the name" />
        <Flex align="center" gap="small" justify="center" vertical={true}>
          <Typography.Title level={3}>
            Your age: &nbsp;
            {result ? result : <QuestionCircleOutlined />}
          </Typography.Title>
          <Button {...buttonProps}>Reset</Button>
        </Flex>
      </Space>
    </Flex>
  );
};

Вся магия происходит в строчке buttonProps: Pick<ButtonProps, "disabled" | "onClick">; и inputProps: Pick<SearchProps, "loading" | "onChange" | "onSearch" | "value">;, где определяется, что тип buttonProps и inputProps соответствует типам ButtonProps и SearchProps, но не полностью. Из их типов с помощью Pick выбираем только те свойства, что будем использовать.

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

Запись buttonProps: Pick<ButtonProps, "disabled" | "onClick">; эквивалентна следующей:

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

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

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

type CustomPick<T extends object, K extends keyof T> = {
  [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> =
  Type extends Promise<infer X>
    ? X extends HttpResponse<infer XX>
      ? XX
      : never
    : never;

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

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

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

  const callApi = React.useCallback(
    ($args: Parameters<ReturnType<ApiGetter>[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<unknown>),
          );

          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<ApiGetter>,
  ReturnType<ApiGetter>[keyof ReturnType<ApiGetter>]
>

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

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<ApiGetter>

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

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

type ReturnType<T> = 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<typeof concat>;
// => string

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

type Concated = ReturnType<string>;
// => any

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

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

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

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

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

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

Tags:
Hubs:
Total votes 10: ↑8 and ↓2+9
Comments2

Articles