Typescript Generics

Javascript is a cool language with its own advantages and disadvantages. And one of its properties is dynamic typing, which can be both an advantage and a disadvantage at the same time. There are a lot of holivar threads about this, but for me it’s so simple. For small and simple projects, dynamic typing is an obvious advantage, as it greatly speeds up development. However, when it comes to complex systems that involve more than one person, it's hard to deny the benefits of static typing. After all, static types not only regulate the system, but also, when the system is large, begin to speed up development.

How is this possible? After all, you have to constantly spend extra time on describing, importing and applying types. It's all about size, although many argue that it is not important. You can keep the logic of a small application in your mind, but this is unlikely to happen with a large one. This is where types will help us, they will tell us what this or that object is without the need to go to it, they will highlight an error if we have passed an incorrect argument to a function, etc.

While writing types can be really tedious, Typescript provides ways to speed up this process as well. This is where generics come to our aid.

Before I begin, I should immediately note that the examples that I will use are available in sandbox, where the light project is assembled. Some solutions in this project were created only to demonstrate the topic and should not be used in real projects.

Generic translated from English means “universal”, that is, generics give us the opportunity to make universal types. By the way, Typescript has a number of built-in utilitarian types (Utility Types), an example of which can be used to understand the principle of operation of generics.

For example, I'll take one of my favorite Utility Types – Pick. Quite often I have to estimate properties for a ready-made UI component from a library from a controller through a Layout component. Here's a simplified example:

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>
  );
};

All the magic happens in a line buttonProps: Pick<ButtonProps, "disabled" | "onClick">; And inputProps: Pick<SearchProps, "loading" | "onChange" | "onSearch" | "value">;where it is determined that the type buttonProps And inputProps matches types ButtonProps And SearchProps, but not completely. Of their types using Pick We select only those properties that we will use.

To dispel all the questions, I’ll write it down more simply:

Record buttonProps: Pick<ButtonProps, "disabled" | "onClick">; is equivalent to the following:

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

In the case of the second type of record, we will not only have to describe all types manually, but also constantly update these records if the types change in the library itself.

If everything is clear with Utility Types – you take it and use it, then how to write your own universal types? Let's write ours Pickto figure this out.

type CustomPick<T extends object, K extends keyof T> = {
  [Key in K]: T[Key];
};

Universality is achieved due to the fact that generic types accept other types as arguments, and can also manipulate them using a number of keywords. In this example, the generic type CustomPick takes two arguments T And K. Type T inherits from type objectand type K inherits the values ​​of the keys of the type object T. Then comes the generic type expression CustomPickusing these arguments. CustomPick is an object in which the key can only be a key belonging to the Union type of object keys Tthat is, the record Key in K equals Key in keyof Tand if they wrote directly according to the type ButtonProps then equals Key in ‘disabled’ | ‘onClick’ | …другие ключи типа ButtonProps. And we provide the value of this key using the entry T[Key].

In this example we saw keywords like extends, in, keyof. There are actually many more of them, but in order to understand the full power of generics, we only need one more thing – infer.

Keyword infer from inference, which translates to “inference”, is one of those keywords that is asked about in interviews, as understanding how this keyword works can reflect how well you know Typescript in general. This is, so to speak, an advanced level.

So what does this keyword do? To discover the principle of its operation, I will again take an example from practice. To work with APIs, they usually generate classes with methods, and also write or use ready-made solutions – helper functions or hooks for centralized work with such classes in order to be able, for example, to generate logs in case of an error or check run time types. Now you'll see an example of a hook that takes full advantage of the power of the keyword. infer and utilitarian type ReturnType for working with this kind of classes. Just don’t be scared, everything is not as complicated as it might seem:

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];
}

I will not describe everything that happens here, since in this case the article would be simply huge. If you are interested in how it all works, then go to sandbox. I will limit myself to a brief description.

This hook is designed to work with API classes, and it doesn’t matter which ones, as long as they meet the typing requirements, namely:

  1. The first argument should be a function that retrieves methods from the API class with signature:

(config: ApiConfig, params: RequestParams) => Record<
  keyof ReturnType<ApiGetter>,
  ReturnType<ApiGetter>[keyof ReturnType<ApiGetter>]
>

Here is an example of such a function:

export const getAgifyApiMethods = (
  config: ApiConfig = {},
  params: RequestParams = {},
) => {
  const baseUrl = "https://api.agify.io";
  return {
    getAge: (query: IAgeQuery) =>
      new ApiClass({ ...config, baseUrl }).getAge(query, params),
  };
};

It accepts the http client's configuration and request parameters, and returns an object with methods that already accept the request body or query parameters and create an instance of the class, passing the configuration, and then calls the desired method, passing the request body, query parameters and the parameters itself request.

  1. The second argument is the desired method:

Method extends keyof ReturnType<ApiGetter>

Here is a signature that is already familiar to us extends keyofwith the help of which we obtain the keys of the object and the same ReturnType. This is enough for us to analyze; you can analyze the remaining parameters yourself if you wish.

Utilitarian type ReturnType returns the type of what the function returns. Let's take a look at its implementation:

type ReturnType<T> = T extends (...args: any[]) => infer R ? R : any;

Keyword infer works only in conditional types – this is also an important part of Typescript, which would also be good to study to understand how generics work. I'll try to explain briefly. Conditional types (Conditional Types) essentially work the same way as the ternary operator in Javascript, only not with values, but with types. The condition here is that it belongs to a specific type; in the case of ReturnType, it is checked that the type T inherit the function interface:

T extends (...args: any[]) => infer R

Signature infer R extracts what is returned when substituted in ReturnType function, for example:

const concat = (a: string, b: string) => a + b:

type Concated = ReturnType<typeof concat>;
// => string

And if you pass something other than a function to this type, the condition will not be satisfied and will return any:

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

To get a sense of the usefulness of such features, let's take a look at how the hook is used useApi:

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

A simple record that allows us to query and process data. At the same time, this function will also tell us what methods the API has, what needs to be passed when called, and what will be returned. Here are some screenshots, you can also check everything in sandbox:

getAge signature

getAge signature

Error when passing a method that does not belong to the passed API class

Error when passing a method that does not belong to the passed API class

Response signature

Response signature

Ibid in sandbox you can look at other generic types that are built on infer and not only.

Thank you for your attention! If you liked the way I write articles, subscribe to my telegram channelwhere you can participate in the selection of topics.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *