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 и не только.

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

Комментарии (2)


  1. aamonster
    03.04.2024 20:02

    Смотрю на слово "extends" вместо значка ⊂ (является подмножеством) в применении к второму аргументу Pick и грущу.


  1. Alexandroppolus
    03.04.2024 20:02

    Наиболее любопытный момент всей истории - extends для параметра ApiGetter в типизации хука useApi. Эта штука позволяет "заморочить голову" тайпскрипту и выключить проверку возможности вызова для api(config ?? {}, params ?? {})[ method ] (...), в чем легко убедиться - result имеет тип any. По факту "методом" может оказаться любое значение, но если это не функция, то для её вызова понадобится аргумент типа never, т.е. вызвать нельзя, TS тут защищает не напрямую, но косвенно. Для более явного ограничения можно написать

    Method extends {
        [K in keyof ReturnType<ApiGetter>]: ReturnType<ApiGetter>[K] extends (...args: never[]) => Promise<HttpResponse<unknown>> ? K : never;
      }[keyof ReturnType<ApiGetter>],

    и теперь в method станет дозволено передать только такое название поля, за которым стоит функция, возвращающая промис HttpResponse