Типизация endpoints — центральный механизм построения безопасного API-слоя в RTK Query. Именно endpoints определяют:
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-функции.
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
Если endpoint не принимает аргументы, используется:
void
Пример:
getProfile: build.query<User, void>({
query: () => 'profile',
})
Использование:
useGetProfileQuery()
Попытка передать аргумент вызовет ошибку TypeScript.
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 использует аналогичную 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',
})
Иногда вместо 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.
Серверный формат часто отличается от формата приложения.
Пример ответа 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
Ошибки также могут преобразовываться.
Пример:
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
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'
Mutation invalidates cache:
createPost: build.mutation<Post, CreatePostDto>({
query: (body) => ({
url: 'posts',
method: 'POST',
body,
}),
invalidatesTags: [{ type: 'Posts', id: 'LIST' }],
})
При строгой типизации tags TypeScript предотвращает ошибки в названиях tag types.
fetchBaseQuery поддерживает meta-информацию.
Пример:
transformResponse: (
response: Post[],
meta
) => {
console.log(meta?.response?.headers)
return response
}
Meta автоматически типизируется как:
FetchBaseQueryMeta
При создании собственного 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
>
Если baseQuery типизирован:
BaseQueryFn<
string,
unknown,
CustomError
>
то hook автоматически получает:
error: CustomError | SerializedError
Пример:
const { error } = useGetUserQuery(1)
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',
}),
}),
})
Типы автоматически объединяются.
RTK Query генерирует hooks автоматически.
Пример:
const {
data,
error,
isLoading,
} = useGetUserQuery(1)
Типы:
data: User | undefined
error: FetchBaseQueryError | SerializedError | undefined
isLoading: boolean
Все типы выводятся из endpoint definition.
Lazy hooks также наследуют типы endpoint.
Пример:
const [
trigger,
result,
] = useLazyGetUserQuery()
Типы:
trigger: (arg: number) => Promise<...>
result.data: User | undefined
selectFromResult сохраняет полную типизацию.
Пример:
const { userName } = useGetUserQuery(1, {
selectFromResult: ({ data }) => ({
userName: data?.name,
}),
})
Тип:
string | undefined
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[]>
Пример:
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>
Сигнатура:
api.util.updateQueryData(
endpointName,
arg,
updater
)
TypeScript автоматически связывает:
Пример:
api.util.updateQueryData(
'getPosts',
undefined,
(draft) => {
draft.push(newPost)
}
)
draft:
Draft<Post[]>
Иногда необходимо кастомизировать cache key.
Пример:
getPosts: build.query<Post[], GetPostsParams>({
query: (params) => ({
url: 'posts',
params,
}),
serializeQueryArgs: ({
endpointName,
queryArgs,
}) => {
return `${endpointName}-${queryArgs.page}`
},
})
queryArgs автоматически типизирован:
GetPostsParams
При 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({
currentArg,
previousArg,
}) {
return currentArg !== previousArg
}
Типы:
currentArg: number | undefined
previousArg: number | undefined
RTK Query хранит endpoint definitions внутри API object.
Пример:
type Endpoints = typeof api.endpoints
Получение конкретного endpoint:
type GetUserEndpoint =
typeof api.endpoints.getUser
Иногда требуется извлечь тип hook.
Пример:
type UseGetUserQuery =
typeof api.useGetUserQuery
Тип trigger mutation:
type CreatePostTrigger =
ReturnType<
typeof api.useCreatePostMutation
>[0]
Можно извлекать типы автоматически.
Пример:
type GetUserResult =
ReturnType<
typeof api.endpoints.getUser.select
>
Полезно при создании утилит:
type EndpointNames =
keyof typeof api.endpoints
Результат:
'type GetUser' | 'getPosts' | ...
RTK Query генерирует selectors.
Пример:
const selectUser =
api.endpoints.getUser.select(1)
Использование:
const result = useSelector(selectUser)
Тип:
{
data?: User
status: QueryStatus
...
}
skipToken используется для условных запросов.
Пример:
import { skipToken } from '@reduxjs/toolkit/query'
const { data } = useGetUserQuery(
userId ?? skipToken
)
TypeScript корректно понимает:
number | SkipToken
Иногда 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)
}
Сервер может вернуть null.
Пример:
getUser: build.query<User | null, number>({
query: (id) => `users/${id}`,
})
Теперь:
data: User | null | undefined
Нужно учитывать три состояния:
| Состояние | Значение |
|---|---|
| запрос не завершён | undefined |
| пользователь отсутствует | null |
| пользователь найден | User |
Пример:
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-типы.
Крупные приложения часто используют 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'
)
Часто типы генерируются автоматически.
Схема:
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}`,
})
В больших приложениях типизация endpoints обычно разделяется на:
Пример структуры:
api/
├── baseApi.ts
├── types/
│ ├── dto/
│ ├── responses/
│ ├── errors/
│ └── common/
├── services/
│ ├── users/
│ ├── posts/
│ └── auth/
Такая архитектура позволяет: