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

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


ссылка на оригинал статьи https://habr.com/ru/articles/805127/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *