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: {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, при чем неважно с какими, главное чтобы они удовлетворяли требованиям типизации, а именно:
Первым аргументом должна быть функция, которая извлекает методы из класса 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-параметры и параметры самого запроса.
Вторым аргументом идет нужный метод:
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 есть методы, что нужно передать при вызове, и что вернется. Вот несколько скринов, также вы все можете проверить в песочнице:



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