Полная типизация API

Полная типизация API в RTK Query обеспечивает строгий контроль структуры запросов, ответов, ошибок, аргументов и состояния кэша. TypeScript начинает выступать не только как инструмент проверки типов, но и как полноценный механизм документирования API-контрактов.

RTK Query тесно интегрирован с TypeScript и способен автоматически выводить типы:

  • параметров запросов;
  • результатов запросов;
  • аргументов мутаций;
  • ошибок;
  • тегов кэша;
  • хуков;
  • селекторов;
  • optimistic updates;
  • lifecycle handlers.

Без полной типизации API постепенно превращается в источник скрытых ошибок:

  • сервер изменил структуру ответа;
  • поле стало nullable;
  • изменился тип идентификатора;
  • endpoint принимает новые параметры;
  • mutation возвращает иной payload.

При строгой типизации подобные изменения обнаруживаются во время компиляции.


Типизация базовых моделей

Основой типизации RTK Query являются интерфейсы и типы доменных сущностей.

Типизация сущности пользователя

export interface User {
    id: number
    name: string
    email: string
    role: 'admin' | 'user'
    createdAt: string
}

Типизация статьи

export interface Article {
    id: number
    title: string
    content: string
    published: boolean
    authorId: number
}

Nullable-поля

export interface Profile {
    id: number
    avatar: string | null
    bio: string | null
}

Вложенные структуры

export interface Comment {
    id: number
    text: string
    author: User
}

Типизация API-ответов

Сервер редко возвращает чистые сущности. Обычно используются обёртки.

Стандартный API Response

export interface ApiResponse<T> {
    success: boolean
    data: T
}

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

ApiResponse<User>
ApiResponse<Article[]>

Типизация пагинации

Универсальный тип пагинации

export interface PaginatedResponse<T> {
    items: T[]
    total: number
    page: number
    pageSize: number
}

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

PaginatedResponse<User>
PaginatedResponse<Article>

Типизация ошибок API

Базовая ошибка

export interface ApiError {
    status: number
    message: string
}

Ошибка валидации

export interface ValidationError {
    field: string
    message: string
}

Расширенная ошибка

export interface ValidationApiError {
    status: number
    message: string
    errors: ValidationError[]
}

Полная типизация createApi

Базовая структура API

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

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

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

    endpoints: () => ({})
})

На первый взгляд типы отсутствуют, однако RTK Query начинает строить их автоматически после описания endpoints.


Типизация query endpoints

Простейший GET-запрос

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

Здесь:

builder.query<ResultType, QueryArg>

где:

  • ResultType — тип ответа;
  • QueryArg — тип аргументов.

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

Если endpoint не принимает аргументов:

builder.query<User[], void>

Тогда хук вызывается без параметров:

const { data } = useGetUsersQuery()

Типизация query с параметрами

Получение пользователя по ID

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

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

const { data } = useGetUserQuery(5)

Попытка передать строку:

useGetUserQuery('5')

вызовет ошибку TypeScript.


Типизация сложных аргументов

Query Params объектом

interface UsersQueryParams {
    page: number
    lim it: number
    search?: string
}

Endpoint:

getUsers: builder.query<
    PaginatedResponse<User>,
    UsersQueryParams
>({
    query: ({ page, limit, search }) => ({
        url: '/users',
        params: {
            page,
            limit,
            search
        }
    })
})

Типизация mutation endpoints

Создание пользователя

interface CreateUserDto {
    name: string
    email: string
}

Mutation:

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

Типизация PATCH-запросов

Частичное обновление

interface UpdateUserDto {
    name?: string
    email?: string
}

Mutation:

updateUser: builder.mutation<
    User,
    { id: number; dat a: UpdateUserDto }
>({
    query: ({ id, data }) => ({
        url: `/users/${id}`,
        method: 'PATCH',
        body: data
    })
})

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

TypeScript позволяет создавать универсальные DTO.

type UpdateUserDto = Partial<CreateUserDto>

Типизация DELETE-запросов

Удаление сущности

deleteUser: builder.mutation<
    { success: boolean },
    number
>({
    query: (id) => ({
        url: `/users/${id}`,
        method: 'DELETE'
    })
})

Полная типизация хуков

RTK Query автоматически создаёт строго типизированные хуки.

Query Hook

const result = useGetUserQuery(1)

Тип:

{
    data?: User
    error?: FetchBaseQueryError | SerializedError
    isLoading: boolean
    isFetching: boolean
    isSuccess: boolean
}

Типизация Mutation Hook

const [createUser, result] = useCreateUserMutation()

Тип функции:

(arg: CreateUserDto) => Promise<any>

RTK Query выводит тип автоматически.


Типизация unwrap

Получение типизированного результата

const user = await createUser({
    name: 'Alex',
    email: 'alex@test.com'
}).unwrap()

Тип:

User

Без unwrap() результат содержит сложный action-объект.


Типизация transformResponse

Преобразование ответа

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

    transformResponse: (
        response: ApiResponse<User[]>
    ) => response.data
})

Тип конечного результата:

User[]

Типизация transformErrorResponse

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

    transformErrorResponse: (
        response: { status: number; dat a: ApiError }
    ) => response.data
})

Типизация кастомного baseQuery

Собственный baseQuery

import {
    BaseQueryFn
} from '@reduxjs/toolkit/query'

Типизированный baseQuery

const customBaseQuery: BaseQueryFn<
    string,
    unknown,
    ApiError
> = async (args) => {
    try {
        const response = await fetch(args)

        const data = await response.json()

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

Типизация FetchBaseQueryError

Проверка ошибок

import {
    FetchBaseQueryError
} from '@reduxjs/toolkit/query'

Проверка:

if ('status' in error) {
    console.log(error.status)
}

Type Guards для ошибок

Пользовательский guard

function isApiError(
    error: unknown
): error is FetchBaseQueryError {
    return typeof error === 'object'
        && error !== null
        && 'status' in error
}

Типизация providesTags

Строгая типизация тегов

tagTypes: ['User', 'Article']

Query:

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

    providesTags: ['User']
})

Типизация динамических тегов

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

    providesTags: (result, error, id) => [
        { type: 'User', id }
    ]
})

TypeScript знает:

id: number

Типизация invalidatesTags

Инвалидация сущности

updateUser: builder.mutation<
    User,
    { id: number; dat a: UpdateUserDto }
>({
    query: ({ id, data }) => ({
        url: `/users/${id}`,
        method: 'PATCH',
        body: data
    }),

    invalidatesTags: (result, error, { id }) => [
        { type: 'User', id }
    ]
})

Типизация optimistic updates

updateQueryData

api.util.updateQueryData(
    'getUser',
    1,
    (draft) => {
        draft.name = 'Upd ated'
    }
)

Тип draft:

Draft<User>

Типизация onQueryStarted

Lifecycle API

updateUser: builder.mutation<
    User,
    { id: number; dat a: UpdateUserDto }
>({
    query: ({ id, data }) => ({
        url: `/users/${id}`,
        method: 'PATCH',
        body: data
    }),

    async onQueryStarted(
        { id, data },
        { dispatch, queryFulfilled }
    ) {
        const patchResult = dispatch(
            api.util.updateQueryData(
                'getUser',
                id,
                (draft) => {
                    Object.assign(draft, data)
                }
            )
        )

        try {
            await queryFulfilled
        } catch {
            patchResult.undo()
        }
    }
})

Типизация queryFulfilled

Тип результата:

{
    data: User
    meta?: FetchBaseQueryMeta
}

Типизация cache entry lifecycle

onCacheEntryAdded

getNotifications: builder.query<
    Notification[],
    void
>({
    query: () => '/notifications',

    async onCacheEntryAdded(
        arg,
        {
            cacheDataLoaded,
            cacheEntryRemoved,
            updateCachedData
        }
    ) {
        await cacheDataLoaded

        const socket = new WebSocket('ws://localhost')

        socket.onmess age = (event) => {
            const data: Notification =
                JSON.parse(event.data)

            updateCachedData((draft) => {
                draft.push(data)
            })
        }

        await cacheEntryRemoved

        socket.close()
    }
})

Типизация updateCachedData

updateCachedData((draft) => {
    draft.push(notification)
})

Тип:

Draft<Notification[]>

Типизация skipToken

Безопасный conditional fetching

import { skipToken } from '@reduxjs/toolkit/query'

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

const result = useGetUserQuery(
    userId ?? skipToken
)

Типизация selectFromResult

Частичная выборка

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

TypeScript выводит:

userName?: string

Типизация lazy queries

useLazyQuery

const [
    trigger,
    result
] = useLazyGetUserQuery()

Тип trigger:

(id: number) => Promise<any>

Типизация polling

Polling options

useGetUsersQuery(undefined, {
    pollingInterval: 5000
})

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


Типизация infinite scrolling

Курсорная пагинация

interface CursorResponse<T> {
    items: T[]
    nextCursor: string | null
}

Endpoint:

getFeed: builder.query<
    CursorResponse<Article>,
    string | null
>({
    query: (cursor) => ({
        url: '/feed',
        params: {
            cursor
        }
    })
})

Типизация uni on response

Иногда API возвращает разные структуры.

Union Type

type AuthResponse =
    | { success: true; token: string }
    | { success: false; error: string }

Mutation:

login: builder.mutation<
    AuthResponse,
    LoginDto
>({
    query: (body) => ({
        url: '/login',
        method: 'POST',
        body
    })
})

Narrowing union types

if (response.success) {
    console.log(response.token)
} else {
    console.log(response.error)
}

Generic endpoints

Универсальный endpoint

interface Entity {
    id: number
}

Generic helper:

function createCrudEndpoints<T extends Entity>(
    builder: any,
    url: string
) {
    return {
        getAll: builder.query<T[], void>({
            query: () => url
        })
    }
}

Типизация injectEndpoints

Разделение API

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

Типизация enhanced endpoints

enhanceEndpoints

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

Типизация response meta

Meta-информация ответа

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

    transformResponse(
        response: User[],
        meta
    ) {
        console.log(meta)

        return response
    }
})

Тип meta зависит от baseQuery.


Типизация headers

Заголовки авторизации

prepareHeaders: (headers, { getState }) => {
    const token =
        (getState() as RootState).auth.token

    if (token) {
        headers.se t(
            'Authorization',
            `Bearer ${token}`
        )
    }

    return headers
}

Типизация RootState

Тип store

export type RootState =
    ReturnType<typeof store.getState>

Типизация dispatch

AppDispatch

export type AppDispatch =
    typeof store.dispatch

Типизация селекторов RTK Query

Endpoint selector

const selectUser =
    api.endpoints.getUser.sel ect(1)

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

const result = useSelector(selectUser)

Тип результата:

QueryResultSelectorResult<User>

Типизация middleware

Middleware API

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

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


Типизация websocket streaming

Streaming updates

interface Message {
    id: number
    text: string
}

Endpoint:

getMessages: builder.query<Message[], void>({
    query: () => '/messages',

    async onCacheEntryAdded(
        arg,
        {
            cacheDataLoaded,
            cacheEntryRemoved,
            updateCachedData
        }
    ) {
        await cacheDataLoaded

        const ws = new WebSocket('ws://localhost')

        ws.onmess age = (event) => {
            const message: Message =
                JSON.parse(event.data)

            updateCachedData((draft) => {
                draft.push(message)
            })
        }

        await cacheEntryRemoved

        ws.close()
    }
})

Типизация нормализованных данных

createEntityAdapter + RTK Query

import {
    createEntityAdapter
} from '@reduxjs/toolkit'

Adapter:

const usersAdapter =
    createEntityAdapter<User>()

Типизация EntityState

EntityState<User>

Нормализация transformResponse

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

    transformResponse: (
        response: User[]
    ) => {
        return usersAdapter.setAll(
            usersAdapter.getInitialState(),
            response
        )
    }
})

Типизация nullable response

Возможный null

getProfile: builder.query<
    Profile | null,
    void
>({
    query: () => '/profile'
})

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

if (data) {
    console.log(data.bio)
}

Типизация enum-значений

Enum roles

export enum UserRole {
    ADMIN = 'admin',
    USER = 'user'
}

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

interface User {
    id: number
    role: UserRole
}

Типизация literal types

Строгие строковые значения

type Status =
    | 'idle'
    | 'loading'
    | 'success'
    | 'error'

Типизация readonly структур

Immutable response

interface Config {
    readonly apiUrl: string
    readonly timeout: number
}

Типизация Record

Словарь сущностей

type UsersMap = Record<number, User>

Типизация utility types

Pick

type UserPreview =
    Pick<User, 'id' | 'name'>

Omit

type PublicUser =
    Omit<User, 'email'>

Required

type FullUser =
    Required<User>

Readonly

type ImmutableUser =
    Readonly<User>

Типизация discriminated unions

Состояния запроса

type RequestState<T> =
    | {
          status: 'loading'
      }
    | {
          status: 'success'
          dat a: T
      }
    | {
          status: 'error'
          error: string
      }

Типизация helper-функций

Универсальный helper

function extractData<T>(
    response: ApiResponse<T>
): T {
    return response.data
}

Стратегии построения типизированного API

Централизация типов

Распространённая структура проекта:

src/
├── api/
├── models/
├── dto/
├── types/
├── services/

Разделение DTO и Entity

DTO не должны полностью совпадать с entity-моделями.

DTO

interface CreateUserDto {
    name: string
    email: string
}

Entity

interface User {
    id: number
    name: string
    email: string
    createdAt: string
}

Ошибки слабой типизации

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

builder.query<any, any>

Полностью отключает преимущества TypeScript.


Опасность unknown без narrowing

const data: unknown

Без проверки типов использование становится невозможным.


Проблемы implicit any

query: (id) => `/users/${id}`

Без строгого TypeScript параметр может стать any.


Рекомендуемые настройки TypeScript

tsconfig.json

{
    "compilerOptions": {
        "strict": true,
        "noImplicitAny": true,
        "strictNullChecks": true,
        "noUncheckedIndexedAccess": true
    }
}

Полностью типизированный API-сервис

Комплексный пример

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

export interface User {
    id: number
    name: string
    email: string
}

export interface CreateUserDto {
    name: string
    email: string
}

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

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

    tagTypes: ['User'],

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

            providesTags: ['User']
        }),

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

            providesTags: (
                result,
                error,
                id
            ) => [
                {
                    type: 'User',
                    id
                }
            ]
        }),

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

            invalidatesTags: ['User']
        })
    })
})

export const {
    useGetUsersQuery,
    useGetUserQuery,
    useCreateUserMutation
} = usersApi