Полная типизация API в RTK Query обеспечивает строгий контроль структуры запросов, ответов, ошибок, аргументов и состояния кэша. TypeScript начинает выступать не только как инструмент проверки типов, но и как полноценный механизм документирования API-контрактов.
RTK Query тесно интегрирован с TypeScript и способен автоматически выводить типы:
Без полной типизации API постепенно превращается в источник скрытых ошибок:
При строгой типизации подобные изменения обнаруживаются во время компиляции.
Основой типизации 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
}
export interface Profile {
id: number
avatar: string | null
bio: string | null
}
export interface Comment {
id: number
text: string
author: User
}
Сервер редко возвращает чистые сущности. Обычно используются обёртки.
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>
export interface ApiError {
status: number
message: string
}
export interface ValidationError {
field: string
message: string
}
export interface ValidationApiError {
status: number
message: string
errors: ValidationError[]
}
import { createApi, fetchBaseQuery } fr om '@reduxjs/toolkit/query/react'
export const api = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({
baseUrl: '/api'
}),
endpoints: () => ({})
})
На первый взгляд типы отсутствуют, однако RTK Query начинает строить их автоматически после описания endpoints.
getUsers: builder.query<User[], void>({
query: () => '/users'
})
Здесь:
builder.query<ResultType, QueryArg>
где:
ResultType — тип ответа;QueryArg — тип аргументов.Если endpoint не принимает аргументов:
builder.query<User[], void>
Тогда хук вызывается без параметров:
const { data } = useGetUsersQuery()
getUser: builder.query<User, number>({
query: (id) => `/users/${id}`
})
Использование:
const { data } = useGetUserQuery(5)
Попытка передать строку:
useGetUserQuery('5')
вызовет ошибку TypeScript.
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
}
})
})
interface CreateUserDto {
name: string
email: string
}
Mutation:
createUser: builder.mutation<User, CreateUserDto>({
query: (body) => ({
url: '/users',
method: 'POST',
body
})
})
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
})
})
TypeScript позволяет создавать универсальные DTO.
type UpdateUserDto = Partial<CreateUserDto>
deleteUser: builder.mutation<
{ success: boolean },
number
>({
query: (id) => ({
url: `/users/${id}`,
method: 'DELETE'
})
})
RTK Query автоматически создаёт строго типизированные хуки.
const result = useGetUserQuery(1)
Тип:
{
data?: User
error?: FetchBaseQueryError | SerializedError
isLoading: boolean
isFetching: boolean
isSuccess: boolean
}
const [createUser, result] = useCreateUserMutation()
Тип функции:
(arg: CreateUserDto) => Promise<any>
RTK Query выводит тип автоматически.
const user = await createUser({
name: 'Alex',
email: 'alex@test.com'
}).unwrap()
Тип:
User
Без unwrap() результат содержит сложный
action-объект.
getUsers: builder.query<User[], void>({
query: () => '/users',
transformResponse: (
response: ApiResponse<User[]>
) => response.data
})
Тип конечного результата:
User[]
getUsers: builder.query<User[], void>({
query: () => '/users',
transformErrorResponse: (
response: { status: number; dat a: ApiError }
) => response.data
})
import {
BaseQueryFn
} from '@reduxjs/toolkit/query'
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'
}
}
}
}
import {
FetchBaseQueryError
} from '@reduxjs/toolkit/query'
Проверка:
if ('status' in error) {
console.log(error.status)
}
function isApiError(
error: unknown
): error is FetchBaseQueryError {
return typeof error === 'object'
&& error !== null
&& 'status' in error
}
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
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 }
]
})
api.util.updateQueryData(
'getUser',
1,
(draft) => {
draft.name = 'Upd ated'
}
)
Тип draft:
Draft<User>
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()
}
}
})
Тип результата:
{
data: User
meta?: FetchBaseQueryMeta
}
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((draft) => {
draft.push(notification)
})
Тип:
Draft<Notification[]>
import { skipToken } from '@reduxjs/toolkit/query'
Использование:
const result = useGetUserQuery(
userId ?? skipToken
)
const { userName } = useGetUserQuery(
1,
{
selectFromResult: ({ data }) => ({
userName: data?.name
})
}
)
TypeScript выводит:
userName?: string
const [
trigger,
result
] = useLazyGetUserQuery()
Тип trigger:
(id: number) => Promise<any>
useGetUsersQuery(undefined, {
pollingInterval: 5000
})
Типизация опций выводится автоматически.
interface CursorResponse<T> {
items: T[]
nextCursor: string | null
}
Endpoint:
getFeed: builder.query<
CursorResponse<Article>,
string | null
>({
query: (cursor) => ({
url: '/feed',
params: {
cursor
}
})
})
Иногда API возвращает разные структуры.
type AuthResponse =
| { success: true; token: string }
| { success: false; error: string }
Mutation:
login: builder.mutation<
AuthResponse,
LoginDto
>({
query: (body) => ({
url: '/login',
method: 'POST',
body
})
})
if (response.success) {
console.log(response.token)
} else {
console.log(response.error)
}
interface Entity {
id: number
}
Generic helper:
function createCrudEndpoints<T extends Entity>(
builder: any,
url: string
) {
return {
getAll: builder.query<T[], void>({
query: () => url
})
}
}
export const extendedApi =
api.injectEndpoints({
endpoints: (builder) => ({
getUsers: builder.query<
User[],
void
>({
query: () => '/users'
})
})
})
const enhancedApi = api.enhanceEndpoints({
addTagTypes: ['User']
})
getUsers: builder.query<User[], void>({
query: () => '/users',
transformResponse(
response: User[],
meta
) {
console.log(meta)
return response
}
})
Тип meta зависит от baseQuery.
prepareHeaders: (headers, { getState }) => {
const token =
(getState() as RootState).auth.token
if (token) {
headers.se t(
'Authorization',
`Bearer ${token}`
)
}
return headers
}
export type RootState =
ReturnType<typeof store.getState>
export type AppDispatch =
typeof store.dispatch
const selectUser =
api.endpoints.getUser.sel ect(1)
Использование:
const result = useSelector(selectUser)
Тип результата:
QueryResultSelectorResult<User>
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(api.middleware)
Типы middleware выводятся автоматически.
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()
}
})
import {
createEntityAdapter
} from '@reduxjs/toolkit'
Adapter:
const usersAdapter =
createEntityAdapter<User>()
EntityState<User>
getUsers: builder.query<
EntityState<User>,
void
>({
query: () => '/users',
transformResponse: (
response: User[]
) => {
return usersAdapter.setAll(
usersAdapter.getInitialState(),
response
)
}
})
getProfile: builder.query<
Profile | null,
void
>({
query: () => '/profile'
})
Использование:
if (data) {
console.log(data.bio)
}
export enum UserRole {
ADMIN = 'admin',
USER = 'user'
}
Использование:
interface User {
id: number
role: UserRole
}
type Status =
| 'idle'
| 'loading'
| 'success'
| 'error'
interface Config {
readonly apiUrl: string
readonly timeout: number
}
type UsersMap = Record<number, User>
type UserPreview =
Pick<User, 'id' | 'name'>
type PublicUser =
Omit<User, 'email'>
type FullUser =
Required<User>
type ImmutableUser =
Readonly<User>
type RequestState<T> =
| {
status: 'loading'
}
| {
status: 'success'
dat a: T
}
| {
status: 'error'
error: string
}
function extractData<T>(
response: ApiResponse<T>
): T {
return response.data
}
Распространённая структура проекта:
src/
├── api/
├── models/
├── dto/
├── types/
├── services/
DTO не должны полностью совпадать с entity-моделями.
interface CreateUserDto {
name: string
email: string
}
interface User {
id: number
name: string
email: string
createdAt: string
}
builder.query<any, any>
Полностью отключает преимущества TypeScript.
const data: unknown
Без проверки типов использование становится невозможным.
query: (id) => `/users/${id}`
Без строгого TypeScript параметр может стать any.
{
"compilerOptions": {
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noUncheckedIndexedAccess": true
}
}
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