Типизация endpoints

Типизация endpoints — центральный механизм построения безопасного API-слоя в RTK Query. Именно endpoints определяют:

  • тип аргументов запроса;
  • тип успешного ответа;
  • тип ошибок;
  • структуру cache tags;
  • сигнатуры автоматически генерируемых hooks;
  • поведение optimistic updates;
  • типы данных внутри selectors и util-функций.

RTK Query построен вокруг generic-типов TypeScript. Практически вся система выводится автоматически из определения endpoint.

Базовый пример:

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

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

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

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

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

Здесь:

build.query<User, number>

означает:

build.query<ResponseType, ArgumentType>

То есть:

User

— тип ответа сервера.

number

— тип аргумента query-функции.


Типизация query endpoints

Полная сигнатура query

build.query<ResultType, QueryArg>

Где:

Generic Назначение
ResultType тип результата
QueryArg тип аргумента

Пример:

interface Post {
  id: number
  title: string
  body: string
}

type GetPostsParams = {
  page: number
  lim it: number
}

getPosts: build.query<Post[], GetPostsParams>({
  query: ({ page, limit }) => ({
    url: 'posts',
    params: {
      page,
      limit,
    },
  }),
})

Тип автоматически применяется:

const { data } = useGetPostsQuery({
  page: 1,
  limit: 10,
})

Тип data:

Post[] | undefined

Типизация query без аргументов

Если endpoint не принимает аргументы, используется:

void

Пример:

getProfile: build.query<User, void>({
  query: () => 'profile',
})

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

useGetProfileQuery()

Попытка передать аргумент вызовет ошибку TypeScript.


Типизация сложных query arguments

RTK Query особенно эффективен при строгой типизации сложных параметров.

Пример:

type SearchUsersArgs = {
  search?: string
  role?: 'admin' | 'user'
  page?: number
  sort?: 'asc' | 'desc'
}

Endpoint:

searchUsers: build.query<User[], SearchUsersArgs>({
  query: (params) => ({
    url: 'users/search',
    params,
  }),
})

TypeScript теперь валидирует:

useSearchUsersQuery({
  role: 'admin',
  sort: 'asc',
})

Ошибка:

useSearchUsersQuery({
  role: 'moderator',
})

Типизация mutation endpoints

Mutation использует аналогичную generic-сигнатуру:

build.mutation<ResultType, MutationArg>

Пример:

interface CreatePostDto {
  title: string
  body: string
}

interface CreatedPost {
  id: number
  title: string
  body: string
}

createPost: build.mutation<CreatedPost, CreatePostDto>({
  query: (body) => ({
    url: 'posts',
    method: 'POST',
    body,
  }),
})

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

const [createPost] = useCreatePostMutation()

await createPost({
  title: 'New post',
  body: 'Content',
})

Типизация queryFn

Иногда вместо query используется queryFn.

Пример:

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

      const data: User = await response.json()

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

Тип результата всё равно определяется первым generic-параметром:

build.query<User, number>

Типизация transformResponse

Очень важный механизм типизации — transformResponse.

Серверный формат часто отличается от формата приложения.

Пример ответа API:

{
  "result": {
    "id": 1,
    "name": "Alex"
  }
}

Типы:

interface UserResponse {
  result: User
}

Endpoint:

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

  transformResponse: (response: UserResponse) => {
    return response.result
  },
})

Теперь hook получает:

User

а не:

UserResponse

Типизация transformErrorResponse

Ошибки также могут преобразовываться.

Пример:

interface ApiError {
  message: string
  code: string
}
getUser: build.query<User, number>({
  query: (id) => `users/${id}`,

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

Теперь error содержит:

ApiError

Типизация providesTags

RTK Query использует tags для invalidation cache.

Пример:

tagTypes: ['Posts']

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

getPosts: build.query<Post[], void>({
  query: () => 'posts',

  providesTags: (result) =>
    result
      ? [
          ...result.map(({ id }) => ({
            type: 'Posts' as const,
            id,
          })),
          { type: 'Posts', id: 'LIST' },
        ]
      : [{ type: 'Posts', id: 'LIST' }],
})

Ключевой момент:

as const

Без него:

type: string

С ним:

type: 'Posts'

Типизация invalidatesTags

Mutation invalidates cache:

createPost: build.mutation<Post, CreatePostDto>({
  query: (body) => ({
    url: 'posts',
    method: 'POST',
    body,
  }),

  invalidatesTags: [{ type: 'Posts', id: 'LIST' }],
})

При строгой типизации tags TypeScript предотвращает ошибки в названиях tag types.


Типизация response meta

fetchBaseQuery поддерживает meta-информацию.

Пример:

transformResponse: (
  response: Post[],
  meta
) => {
  console.log(meta?.response?.headers)

  return response
}

Meta автоматически типизируется как:

FetchBaseQueryMeta

Типизация custom baseQuery

При создании собственного baseQuery типизация становится значительно важнее.

Пример:

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

type CustomError = {
  message: string
}

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

    const data = await response.json()

    return { data }
  } catch {
    return {
      error: {
        message: 'Request failed',
      },
    }
  }
}

Здесь:

BaseQueryFn<
  Args,
  Result,
  Error
>

Типизация error в hooks

Если baseQuery типизирован:

BaseQueryFn<
  string,
  unknown,
  CustomError
>

то hook автоматически получает:

error: CustomError | SerializedError

Пример:

const { error } = useGetUserQuery(1)

Типизация injectEndpoints

RTK Query поддерживает code splitting через injectEndpoints.

Базовый API:

export const api = createApi({
  reducerPath: 'api',
  baseQuery: fetchBaseQuery({
    baseUrl: '/api',
  }),
  endpoints: () => ({}),
})

Инъекция:

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

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


Типизация generated hooks

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

Пример:

const {
  data,
  error,
  isLoading,
} = useGetUserQuery(1)

Типы:

data: User | undefined
error: FetchBaseQueryError | SerializedError | undefined
isLoading: boolean

Все типы выводятся из endpoint definition.


Типизация lazy queries

Lazy hooks также наследуют типы endpoint.

Пример:

const [
  trigger,
  result,
] = useLazyGetUserQuery()

Типы:

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

result.data: User | undefined

Типизация selectFromResult

selectFromResult сохраняет полную типизацию.

Пример:

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

Тип:

string | undefined

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

RTK Query поддерживает lifecycle callbacks.

Пример:

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

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

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

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

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

    await cacheEntryRemoved

    socket.close()
  },
})

Тип draft автоматически становится:

Draft<Notification[]>

Типизация optimistic updates

Пример:

updatePost: build.mutation<
  Post,
  Partial<Post> & Pick<Post, 'id'>
>({
  query: ({ id, ...patch }) => ({
    url: `posts/${id}`,
    method: 'PATCH',
    body: patch,
  }),

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

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

Тип draft автоматически:

Draft<Post>

Типизация util.updateQueryData

Сигнатура:

api.util.updateQueryData(
  endpointName,
  arg,
  updater
)

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

  • endpoint;
  • тип аргумента;
  • тип cached data.

Пример:

api.util.updateQueryData(
  'getPosts',
  undefined,
  (draft) => {
    draft.push(newPost)
  }
)

draft:

Draft<Post[]>

Типизация serializeQueryArgs

Иногда необходимо кастомизировать cache key.

Пример:

getPosts: build.query<Post[], GetPostsParams>({
  query: (params) => ({
    url: 'posts',
    params,
  }),

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

queryArgs автоматически типизирован:

GetPostsParams

Типизация merge

При infinite scroll используется merge.

Пример:

getPosts: build.query<Post[], number>({
  query: (page) => `posts?page=${page}`,

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

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

Типы:

currentCache: Post[]
newItems: Post[]

Типизация forceRefetch

Пример:

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

Типы:

currentArg: number | undefined
previousArg: number | undefined

Типизация endpoint definitions

RTK Query хранит endpoint definitions внутри API object.

Пример:

type Endpoints = typeof api.endpoints

Получение конкретного endpoint:

type GetUserEndpoint =
  typeof api.endpoints.getUser

Типизация hooks вручную

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

Пример:

type UseGetUserQuery =
  typeof api.useGetUserQuery

Тип trigger mutation:

type CreatePostTrigger =
  ReturnType<
    typeof api.useCreatePostMutation
  >[0]

Типизация ResultType через infer

Можно извлекать типы автоматически.

Пример:

type GetUserResult =
  ReturnType<
    typeof api.endpoints.getUser.select
  >

Типизация endpoint names

Полезно при создании утилит:

type EndpointNames =
  keyof typeof api.endpoints

Результат:

'type GetUser' | 'getPosts' | ...

Типизация query selectors

RTK Query генерирует selectors.

Пример:

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

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

const result = useSelector(selectUser)

Тип:

{
  data?: User
  status: QueryStatus
  ...
}

Типизация skipToken

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

Пример:

import { skipToken } from '@reduxjs/toolkit/query'
const { data } = useGetUserQuery(
  userId ?? skipToken
)

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

number | SkipToken

Типизация polymorphic responses

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

Пример:

type ApiResponse =
  | { type: 'success'; dat a: User }
  | { type: 'error'; message: string }

Endpoint:

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

Type narrowing:

if (data?.type === 'success') {
  console.log(data.data.name)
}

Типизация nullable responses

Сервер может вернуть null.

Пример:

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

Теперь:

data: User | null | undefined

Нужно учитывать три состояния:

Состояние Значение
запрос не завершён undefined
пользователь отсутствует null
пользователь найден User

Типизация union query args

Пример:

type UserQuery =
  | { id: number }
  | { email: string }
getUser: build.query<User, UserQuery>({
  query: (arg) => {
    if ('id' in arg) {
      return `users/${arg.id}`
    }

    return `users/by-email/${arg.email}`
  },
})

TypeScript корректно сужает union-типы.


Типизация generic endpoint factories

Крупные приложения часто используют endpoint factories.

Пример:

function createCrudEndpoints<T>(
  build: EndpointBuilder<any, any, any>,
  resource: string
) {
  return {
    getAll: build.query<T[], void>({
      query: () => resource,
    }),

    getById: build.query<T, number>({
      query: (id) => `${resource}/${id}`,
    }),
  }
}

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

const userEndpoints =
  createCrudEndpoints<User>(
    build,
    'users'
  )

Типизация RTK Query вместе с OpenAPI

Часто типы генерируются автоматически.

Схема:

interface paths {
  '/users/{id}': {
    get: {
      responses: {
        200: {
          content: {
            'application/json': User
          }
        }
      }
    }
  }
}

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

type UserResponse =
  paths['/users/{id}']['get']['responses'][200]['content']['application/json']

Endpoint:

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

Типизация large-scale API architecture

В больших приложениях типизация endpoints обычно разделяется на:

  • DTO;
  • domain models;
  • response wrappers;
  • error contracts;
  • query args;
  • cache tags;
  • shared utility types.

Пример структуры:

api/
├── baseApi.ts
├── types/
│   ├── dto/
│   ├── responses/
│   ├── errors/
│   └── common/
├── services/
│   ├── users/
│   ├── posts/
│   └── auth/

Такая архитектура позволяет:

  • избегать дублирования типов;
  • переиспользовать generic utilities;
  • централизованно менять API contracts;
  • обеспечивать полную типовую безопасность всего data layer;
  • минимизировать runtime-ошибки.