Typescript Generics
- четверг, 4 апреля 2024 г. в 00:00:08
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
и не только.
Спасибо за внимание! Если вам понравилось то, как я пишу статьи, подписывайтесь на мой телеграм-канал, где вы сможете участвовать в выборе тем.