Инференс типов

Инференс типов — механизм автоматического вывода типов TypeScript на основе переданных параметров, возвращаемых значений и структуры API. В RTK Query инференс играет ключевую роль, поскольку библиотека активно использует дженерики, условные типы и вывод типов через функции.

Грамотно настроенный инференс позволяет:

  • автоматически получать типизированные хуки;
  • исключать дублирование интерфейсов;
  • избегать ручного указания типов;
  • получать автодополнение endpoint-ов;
  • контролировать структуру ошибок;
  • корректно типизировать cache tags;
  • выводить типы аргументов mutation и query;
  • типизировать transformResponse и transformErrorResponse.

RTK Query проектировался как deeply-typed API layer, поэтому значительная часть возможностей библиотеки завязана именно на выводе типов.


Инференс типов при создании API

Основная точка входа — createApi.

import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'

export const api = createApi({
    reducerPath: 'api',

    baseQuery: fetchBaseQuery({
        baseUrl: '/api'
    }),

    endpoints: (builder) => ({
        getUsers: builder.query({
            query: () => '/users'
        })
    })
})

В этом примере RTK Query не знает:

  • структуру ответа;
  • тип аргументов;
  • тип ошибок;
  • тип meta;
  • структуру transformResponse.

Поэтому типы становятся слишком широкими:

data: unknown
arg: void

Для корректного инференса используются generic-параметры.


Инференс результата query

Сигнатура builder.query:

builder.query<ResultType, QueryArg>()

Пример:

interface User {
    id: number
    name: string
}

const api = createApi({
    reducerPath: 'api',

    baseQuery: fetchBaseQuery({
        baseUrl: '/api'
    }),

    endpoints: (builder) => ({
        getUsers: builder.query<User[], void>({
            query: () => '/users'
        })
    })
})

Теперь RTK Query автоматически выводит:

const { data } = api.useGetUsersQuery()

Тип:

data?: User[]

Инференс распространяется на:

  • hook;
  • endpoint;
  • select;
  • initiate;
  • cache;
  • providesTags;
  • lifecycle callbacks.

Инференс аргументов query

Второй generic отвечает за аргументы endpoint-а.

interface User {
    id: number
    name: string
}

const api = createApi({
    reducerPath: 'api',

    baseQuery: fetchBaseQuery({
        baseUrl: '/api'
    }),

    endpoints: (builder) => ({
        getUser: builder.query<User, number>({
            query: (id) => `/users/${id}`
        })
    })
})

RTK Query выводит:

api.useGetUserQuery(5)

Корректно:

api.useGetUserQuery(5)

Ошибка:

api.useGetUserQuery('5')

TypeScript:

Argument of type 'string' is not assignable to parameter of type 'number'

Инференс для mutation

Mutation работает аналогично.

interface User {
    id: number
    name: string
}

interface CreateUserDto {
    name: string
}

const api = createApi({
    reducerPath: 'api',

    baseQuery: fetchBaseQuery({
        baseUrl: '/api'
    }),

    endpoints: (builder) => ({
        createUser: builder.mutation<User, CreateUserDto>({
            query: (body) => ({
                url: '/users',
                method: 'POST',
                body
            })
        })
    })
})

Инференс:

const [createUser] = api.useCreateUserMutation()

Тип функции:

(body: CreateUserDto) => Promise<...>

Инференс useQuery hooks

RTK Query генерирует hooks автоматически.

const { data, error, isLoading } = api.useGetUsersQuery()

Типы выводятся автоматически:

data?: User[]
error?: FetchBaseQueryError | SerializedError
isLoading: boolean

Без ручной типизации.


Инференс useMutation hooks

const [createUser, result] = api.useCreateUserMutation()

RTK Query выводит:

createUser: MutationTrigger<CreateUserDto>

И:

result.data?: User

Инференс unwrap()

Метод unwrap() особенно важен для корректного вывода типов.

const handleCreate = async () => {
    const user = await createUser({
        name: 'Alex'
    }).unwrap()

    console.log(user.id)
}

Тип:

user: User

Без unwrap() тип будет сложным Promise-like объектом RTK Query.


Инференс transformResponse

transformResponse участвует в вычислении итогового типа endpoint-а.

interface UserResponse {
    data: User[]
}

const api = createApi({
    reducerPath: 'api',

    baseQuery: fetchBaseQuery({
        baseUrl: '/api'
    }),

    endpoints: (builder) => ({
        getUsers: builder.query<User[], void>({
            query: () => '/users',

            transformResponse: (
                response: UserResponse
            ) => response.data
        })
    })
})

Тип hook-а:

data?: User[]

Несмотря на то что сервер возвращает:

{
    data: User[]
}

Инференс transformErrorResponse

interface ApiError {
    message: string
    code: string
}

const api = createApi({
    reducerPath: 'api',

    baseQuery: fetchBaseQuery({
        baseUrl: '/api'
    }),

    endpoints: (builder) => ({
        getUsers: builder.query<User[], void>({
            query: () => '/users',

            transformErrorResponse: (
                response: { data: ApiError }
            ) => response.data
        })
    })
})

RTK Query корректно выводит тип transformed error.


Инференс providesTags

Tags также участвуют в типизации.

type TagTypes = 'Users'

const api = createApi({
    reducerPath: 'api',

    tagTypes: ['Users'],

    baseQuery: fetchBaseQuery({
        baseUrl: '/api'
    }),

    endpoints: (builder) => ({
        getUsers: builder.query<User[], void>({
            query: () => '/users',

            providesTags: ['Users']
        })
    })
})

Ошибка:

providesTags: ['Unknown']

TypeScript:

Type '"Unknown"' is not assignable

Инференс invalidatesTags

createUser: builder.mutation<User, CreateUserDto>({
    query: (body) => ({
        url: '/users',
        method: 'POST',
        body
    }),

    invalidatesTags: ['Users']
})

RTK Query автоматически связывает:

  • mutation;
  • query cache;
  • invalidation system.

Инференс через ReturnType

Иногда полезно извлекать типы из endpoint-ов.

type GetUsersResult =
    ReturnType<typeof api.endpoints.getUsers.select>

Или:

type GetUserHook =
    typeof api.useGetUserQuery

Инференс через typeof

type ApiType = typeof api

Позволяет:

  • переиспользовать тип API;
  • строить generic utilities;
  • создавать middleware helpers.

Инференс initiate()

RTK Query генерирует strongly typed initiate actions.

dispatch(
    api.endpoints.getUser.initiate(5)
)

Тип аргумента:

number

Результат:

QueryActionCreatorResult<User>

Инференс sel ect()

const selectUser =
    api.endpoints.getUser.select(1)

Типизированный selector:

(state) => QueryResultSelectorResult<User>

Инференс cache entry

В lifecycle callbacks RTK Query выводит тип cache entry автоматически.

getUser: builder.query<User, number>({
    query: (id) => `/users/${id}`,

    async onCacheEntryAdded(
        arg,
        api
    ) {

    }
})

Типы:

arg: number

И:

api.getCacheEntry(): QueryResultSelectorResult<User>

Инференс queryFn

При использовании queryFn типы становятся особенно важными.

getUser: builder.query<User, number>({
    async queryFn(id) {
        try {
            const response = await fetch(
                `/users/${id}`
            )

            const data: User =
                await response.json()

            return { data }

        } catch (error) {
            return {
                error: {
                    status: 500,
                    data: error
                }
            }
        }
    }
})

RTK Query выводит:

  • User;
  • number;
  • error type.

Инференс baseQuery

Типизация baseQuery влияет на все endpoint-ы.

const baseQuery = fetchBaseQuery({
    baseUrl: '/api'
})

Тип:

BaseQueryFn<
    string | FetchArgs,
    unknown,
    FetchBaseQueryError
>

Кастомный baseQuery и инференс

type ApiError = {
    message: string
}

const customBaseQuery:
    BaseQueryFn<
        string,
        unknown,
        ApiError
    > = async (url) => {

    try {
        const response = await fetch(url)

        const data = await response.json()

        return { data }

    } catch (e) {
        return {
            error: {
                message: 'Server error'
            }
        }
    }
}

Теперь все endpoint-ы получают:

error: ApiError

Инференс injectEndpoints

RTK Query сохраняет типы даже после расширения API.

const extendedApi = api.injectEndpoints({
    endpoints: (builder) => ({
        getPosts: builder.query<Post[], void>({
            query: () => '/posts'
        })
    })
})

Типы автоматически объединяются.

extendedApi.useGetPostsQuery()
extendedApi.useGetUsersQuery()

Инференс enhanceEndpoints

const enhancedApi = api.enhanceEndpoints({
    addTagTypes: ['Posts']
})

RTK Query расширяет union tag types.


Инференс endpoint definitions

Иногда требуется извлечь тип endpoint-а.

type GetUsersEndpoint =
    typeof api.endpoints.getUsers

Полезно для:

  • abstraction layers;
  • reusable helpers;
  • middleware;
  • тестирования.

Инференс query hooks options

api.useGetUsersQuery(undefined, {
    pollingInterval: 5000,
    skip: false
})

Options также полностью типизированы.


Инференс lazy queries

const [trigger, result] =
    api.useLazyGetUserQuery()

Тип trigger:

(arg: number) => Promise<...>

Инференс skipToken

RTK Query поддерживает специальный типизированный token.

import { skipToken } fr om '@reduxjs/toolkit/query'

const result = api.useGetUserQuery(
    userId ?? skipToken
)

TypeScript корректно понимает union:

number | typeof skipToken

Инференс selectFromResult

const { userName } =
    api.useGetUserQuery(1, {
        selectFromResult: ({
            data
        }) => ({
            userName: data?.name
        })
    })

Тип:

userName?: string

Инференс polling и refetch

api.useGetUsersQuery(undefined, {
    pollingInterval: 1000,
    refetchOnFocus: true,
    refetchOnReconnect: true
})

Все параметры строго типизированы.


Инференс utility types RTK Query

RTK Query экспортирует большое количество utility types.

Примеры:

QueryDefinition
MutationDefinition
BaseQueryFn
FetchArgs
FetchBaseQueryError
EndpointDefinitions

QueryDefinition

type UserQueryDefinition =
    QueryDefinition<
        number,
        BaseQueryFn,
        'Users',
        User
    >

Используется:

  • внутри abstraction layers;
  • reusable endpoint factories;
  • generic helpers.

MutationDefinition

type CreateUserMutation =
    MutationDefinition<
        CreateUserDto,
        BaseQueryFn,
        'Users',
        User
    >

Инференс endpoint factories

Один из наиболее сложных сценариев.

const createCrudEndpoints = <
    TEntity,
    TCreateDto
>(
    builder: EndpointBuilder<any, any, any>,
    route: string
) => ({
    getAll: builder.query<TEntity[], void>({
        query: () => route
    }),

    create: builder.mutation<
        TEntity,
        TCreateDto
    >({
        query: (body) => ({
            url: route,
            method: 'POST',
            body
        })
    })
})

RTK Query способен корректно вывести generic-типы endpoint-ов.


Проблемы потери инференса

Наиболее частые причины:

Использование any

builder.query<any, any>()

После этого типизация практически исчезает.


Отсутствие generic-параметров

builder.query({
    query: () => '/users'
})

Результат:

unknown

Неправильный transformResponse

transformResponse: (response) => {
    return response.data
}

Без явной типизации:

response: any

Слишком широкий union

string | number | boolean | object

RTK Query начинает терять точность типов.


Улучшение инференса через satisfies

Современный TypeScript поддерживает satisfies.

const tags = ['Users'] as const

Или:

const providesTags = (
    result: User[]
) => result.map(user => ({
    type: 'Users',
    id: user.id
})) satisfies readonly {
    type: 'Users'
    id: number
}[]

Это позволяет:

  • сохранять literal types;
  • избегать widening;
  • улучшать inference.

Инференс literal types

tagTypes: ['Users']

Лучше:

tagTypes: ['Users'] as const

Иначе:

string[]

вместо:

readonly ['Users']

Инференс union endpoint-ов

type ApiEndpoints =
    keyof typeof api.endpoints

Результат:

'getUsers' | 'getUser' | 'createUser'

Инференс dispatch типов

type AppDispatch = typeof store.dispatch

RTK Query автоматически интегрируется с dispatch typing.


Инференс RootState

type RootState =
    ReturnType<typeof store.getState>

Селекторы RTK Query используют этот тип автоматически.


Инференс middleware

middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware().concat(
        api.middleware
    )

Middleware получает типизированный store.


Инференс serializeQueryArgs

serializeQueryArgs: ({
    endpointName,
    queryArgs
}) => {
    return `${endpointName}-${queryArgs}`
}

Тип queryArgs выводится из endpoint-а.


Инференс merge()

merge: (
    currentCache,
    newItems
) => {
    currentCache.push(...newItems)
}

Типы:

currentCache: User[]
newItems: User[]

Инференс forceRefetch

forceRefetch({
    currentArg,
    previousArg
}) {
    return currentArg !== previousArg
}

Типы выводятся автоматически.


Deep inference в RTK Query

RTK Query использует сложную систему вложенного вывода типов:

  • conditional types;
  • distributive unions;
  • mapped types;
  • infer keyword;
  • overload inference;
  • generic propagation.

Благодаря этому библиотека способна автоматически синхронизировать типы между:

  • endpoint;
  • hooks;
  • selectors;
  • cache;
  • middleware;
  • actions;
  • lifecycle callbacks;
  • dispatch;
  • store.

Практика построения fully inferred API

Наиболее качественная архитектура обычно выглядит так:

export interface User {
    id: number
    name: string
}

export interface CreateUserDto {
    name: string
}

export const api = createApi({
    reducerPath: 'api',

    baseQuery: fetchBaseQuery({
        baseUrl: '/api'
    }),

    tagTypes: ['Users'] as const,

    endpoints: (builder) => ({
        getUsers: builder.query<
            User[],
            void
        >({
            query: () => '/users',

            providesTags: ['Users']
        }),

        createUser: builder.mutation<
            User,
            CreateUserDto
        >({
            query: (body) => ({
                url: '/users',
                method: 'POST',
                body
            }),

            invalidatesTags: ['Users']
        })
    })
})

В результате:

  • endpoint-ы строго типизированы;
  • hooks получают автодополнение;
  • dispatch знает типы actions;
  • selectors типизированы;
  • cache типизирован;
  • invalidation контролируется TypeScript;
  • lifecycle callbacks получают корректные generic-типы;
  • отсутствует необходимость ручного кастинга.