В больших приложениях RTK Query часто используется совместно с TypeScript. Однако даже при наличии статической типизации возникает проблема синхронизации типов между сервером и клиентом. Если API изменилось, а клиентские интерфейсы остались прежними, типы перестают отражать реальную структуру данных.
Генерация типов из схемы решает эту проблему автоматически. Источником правды становится серверная спецификация:
RTK Query особенно хорошо сочетается с автоматической генерацией типов, поскольку:
Типизация в RTK Query строится вокруг нескольких сущностей:
type QueryArg = {
id: number
}
type Post = {
id: number
title: string
}
getPost: build.query<Post, QueryArg>({
query: ({ id }) => `/posts/${id}`
})
Внутри используются:
При ручной типизации количество интерфейсов быстро растёт:
interface User {}
interface UserList {}
interface CreateUserRequest {}
interface CreateUserResponse {}
interface UpdateUserRequest {}
interface ApiError {}
Генерация типов устраняет необходимость поддерживать всё вручную.
Наиболее распространённый подход — использование OpenAPI.
OpenAPI описывает:
Пример OpenAPI-схемы:
paths:
/users/{id}:
get:
parameters:
- in: path
name: id
required: true
schema:
type: integer
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/User'
components:
schemas:
User:
type: object
properties:
id:
type: integer
name:
type: string
На основе этой схемы можно автоматически получить:
type User = {
id: number
name: string
}
Одним из самых популярных инструментов является openapi-typescript.
Установка:
npm install openapi-typescript --save-dev
Генерация типов:
npx openapi-typescript ./schema.yaml -o ./src/types/api.ts
Результат:
export interface paths {
"/users/{id}": {
get: {
responses: {
200: {
content: {
"application/json": components["schemas"]["User"]
}
}
}
}
}
}
export interface components {
schemas: {
User: {
id: number
name: string
}
}
}
После генерации типов их можно подключить в API slice.
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
import type { components } from './types/api'
type User = components['schemas']['User']
export const api = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({
baseUrl: '/api'
}),
endpoints: (build) => ({
getUser: build.query<User, number>({
query: (id) => `/users/${id}`
})
})
})
Теперь:
data строго типизирован;useGetUserQuery знает структуру ответа;RTK Query поддерживает автоматическую генерацию endpoint-ов через пакет @rtk-query/codegen-openapi.
Установка:
npm install @rtk-query/codegen-openapi --save-dev
Конфигурация:
import { defineConfig } from '@rtk-query/codegen-openapi'
export default defineConfig({
schemaFile: './openapi.yaml',
apiFile: './src/store/emptyApi.ts',
outputFile: './src/store/generatedApi.ts',
exportName: 'generatedApi',
hooks: true
})
Пустой API:
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
export const emptyApi = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({
baseUrl: '/api'
}),
endpoints: () => ({})
})
Генерация:
npx @rtk-query/codegen-openapi openapi-config.ts
Результат генерации:
export const injectedRtkApi = api.injectEndpoints({
endpoints: (build) => ({
getUsers: build.query<GetUsersApiResponse, GetUsersApiArg>({
query: () => ({ url: `/users` })
}),
getUserById: build.query<
GetUserByIdApiResponse,
GetUserByIdApiArg
>({
query: (queryArg) => ({
url: `/users/${queryArg.id}`
})
})
})
})
Также автоматически создаются:
export const {
useGetUsersQuery,
useGetUserByIdQuery
} = injectedRtkApi
OpenAPI позволяет генерировать типы тела запроса.
Схема:
CreateUserRequest:
type: object
required:
- name
properties:
name:
type: string
age:
type: integer
Generated type:
type CreateUserRequest = {
name: string
age?: number
}
Использование:
createUser: build.mutation<User, CreateUserRequest>({
query: (body) => ({
url: '/users',
method: 'POST',
body
})
})
Теперь невозможно случайно отправить некорректный payload.
OpenAPI различает:
Пример:
email:
type: string
nullable: true
Generated type:
email: string | null
Optional поле:
email:
type: string
Результат:
email?: string
Разница критически важна:
// поле отсутствует
{}
// поле присутствует, но равно null
{
email: null
}
Схема:
status:
type: string
enum:
- pending
- active
- blocked
Generated type:
status: 'pending' | 'active' | 'blocked'
RTK Query начинает автоматически проверять допустимые значения:
user.status = 'deleted'
// ошибка TypeScript
OpenAPI поддерживает:
Пример:
oneOf:
- $ref: '#/components/schemas/Cat'
- $ref: '#/components/schemas/Dog'
Generated type:
type Animal = Cat | Dog
RTK Query способен работать с discriminated unions:
if (animal.type === 'cat') {
animal.meow()
}
Ошибка — одна из самых сложных частей типизации.
Схема:
ErrorResponse:
type: object
properties:
message:
type: string
code:
type: string
Тип:
type ErrorResponse = {
message: string
code: string
}
Использование:
baseQuery: fetchBaseQuery({
baseUrl: '/api'
})
С transformErrorResponse:
getUser: build.query<User, number>({
query: (id) => `/users/${id}`,
transformErrorResponse: (
response: { data: ErrorResponse }
) => response.data
})
RTK Query поддерживает изменение структуры ответа.
Пример:
type UserDto = {
id: number
full_name: string
}
type User = {
id: number
fullName: string
}
Endpoint:
getUser: build.query<User, number>({
query: (id) => `/users/${id}`,
transformResponse: (response: UserDto): User => ({
id: response.id,
fullName: response.full_name
})
})
При генерации типов важно разделять:
DTO:
type UserDto = {
created_at: string
}
Domain model:
type User = {
createdAt: Date
}
RTK Query получает DTO:
transformResponse: (dto: UserDto): User => ({
createdAt: new Date(dto.created_at)
})
Автоматическая генерация особенно полезна именно для DTO-моделей.
RTK Query можно использовать вместе с GraphQL.
Популярный инструмент:
Установка:
npm install @graphql-codegen/cli --save-dev
Конфигурация:
schema: http://localhost:4000/graphql
documents: src/**/*.graphql
generates:
src/generated/graphql.ts:
plugins:
- typescript
- typescript-operations
Генерация:
npx graphql-codegen
import {
GetUsersQuery
} from './generated/graphql'
getUsers: build.query<GetUsersQuery, void>({
query: () => ({
document: GET_USERS_QUERY
})
})
Схема:
PaginatedUsers:
type: object
properties:
items:
type: array
items:
$ref: '#/components/schemas/User'
total:
type: integer
page:
type: integer
Generated type:
type PaginatedUsers = {
items: User[]
total: number
page: number
}
Использование:
getUsers: build.query<
PaginatedUsers,
{ page: number }
>({
query: ({ page }) => `/users?page=${page}`
})
OpenAPI умеет генерировать типы параметров запроса.
Схема:
parameters:
- in: query
name: page
schema:
type: integer
- in: query
name: search
schema:
type: string
Generated type:
type GetUsersParams = {
page?: number
search?: string
}
Использование:
getUsers: build.query<User[], GetUsersParams>({
query: (params) => ({
url: '/users',
params
})
})
/users/{id}
Generated type:
type GetUserArg = {
id: number
}
Endpoint:
getUser: build.query<User, GetUserArg>({
query: ({ id }) => `/users/${id}`
})
OpenAPI может описывать headers:
headers:
X-Request-Id:
schema:
type: string
Типы можно использовать внутри custom baseQuery:
type HeadersResponse = {
'x-request-id': string
}
PATCH-запросы особенно выигрывают от генерации типов.
Исходная модель:
type User = {
id: number
name: string
email: string
}
PATCH-модель:
type UpdateUserRequest = Partial<User>
RTK Query:
updateUser: build.mutation<
User,
{ id: number; body: UpdateUserRequest }
>({
query: ({ id, body }) => ({
url: `/users/${id}`,
method: 'PATCH',
body
})
})
Tags тоже можно типизировать.
tagTypes: ['User', 'Post'] as const
Либо:
type Tag = 'User' | 'Post'
RTK Query предотвращает ошибки:
providesTags: ['Users']
// ошибка
Некоторые команды генерируют полноценный SDK:
sdk.users.getById()
sdk.posts.create()
sdk.auth.login()
RTK Query может использовать такой SDK внутри queryFn:
getUser: build.query<User, number>({
async queryFn(id) {
try {
const data = await sdk.users.getById(id)
return { data }
} catch (error) {
return { error }
}
}
})
TypeScript проверяет типы только во время компиляции.
Для runtime validation часто используются:
Пример с Zod:
const UserSchema = z.object({
id: z.number(),
name: z.string()
})
type User = z.infer<typeof UserSchema>
RTK Query:
transformResponse: (response) => {
return UserSchema.parse(response)
}
Существуют инструменты:
Они генерируют:
Популярный генератор:
Конфигурация:
module.exports = {
api: {
input: './openapi.yaml',
output: {
target: './src/api.ts',
client: 'react-query'
}
}
}
Хотя Orval чаще используют с React Query, generated types можно подключать и в RTK Query.
В крупных проектах генерация запускается автоматически:
{
"scripts": {
"generate:api": "openapi-typescript schema.yaml -o src/types/api.ts"
}
}
CI pipeline:
npm run generate:api
npm run typecheck
Типичные проблемы:
Автогенерация решает проблему только при условии:
Распространённый подход:
/api/v1
/api/v2
Для каждой версии:
openapi-v1.yaml
openapi-v2.yaml
Генерация:
src/generated/v1
src/generated/v2
Типичная структура:
src/
├── api/
│ ├── generated/
│ │ ├── types.ts
│ │ ├── endpoints.ts
│ │ └── hooks.ts
│ │
│ ├── baseApi.ts
│ └── customApi.ts
Generated-файлы обычно:
RTK Query поддерживает injectEndpoints.
Generated API:
export const generatedApi = createApi(...)
Расширение:
export const extendedApi =
generatedApi.injectEndpoints({
endpoints: (build) => ({
customEndpoint: build.query({
query: () => '/custom'
})
})
})
Это позволяет:
В очень больших API возникают сложности:
Способы решения:
Максимальную пользу генерация приносит только при:
{
"strict": true
}
Особенно важны:
{
"noImplicitAny": true,
"strictNullChecks": true,
"exactOptionalPropertyTypes": true
}
Без strictNullChecks теряется смысл nullable-типизации.
Infinite pagination:
type PageResponse<T> = {
items: T[]
nextCursor?: string
}
RTK Query:
getFeed: build.query<
PageResponse<Post>,
{ cursor?: string }
>({
query: ({ cursor }) => ({
url: '/feed',
params: { cursor }
})
})
Generated types хорошо подходят для cursor-based pagination.
Часто схема и generated types располагаются в отдельном пакете:
packages/
├── api-schema/
├── api-types/
├── frontend/
└── backend/
Frontend импортирует:
import { User } from '@company/api-types'
Так достигается единый источник правды для всех приложений.