Генерация типов из схемы

В больших приложениях RTK Query часто используется совместно с TypeScript. Однако даже при наличии статической типизации возникает проблема синхронизации типов между сервером и клиентом. Если API изменилось, а клиентские интерфейсы остались прежними, типы перестают отражать реальную структуру данных.

Генерация типов из схемы решает эту проблему автоматически. Источником правды становится серверная спецификация:

  • OpenAPI
  • Swagger
  • GraphQL Schema
  • JSON Schema
  • protobuf/gRPC
  • custom schema registry

RTK Query особенно хорошо сочетается с автоматической генерацией типов, поскольку:

  • endpoint-ы уже описываются декларативно;
  • типы запроса и ответа можно подставлять автоматически;
  • хуки получают полную типизацию;
  • уменьшается количество ручного кода;
  • снижается риск рассинхронизации API.

Архитектура типизации RTK Query

Типизация в RTK Query строится вокруг нескольких сущностей:

type QueryArg = {
  id: number
}

type Post = {
  id: number
  title: string
}

getPost: build.query<Post, QueryArg>({
  query: ({ id }) => `/posts/${id}`
})

Внутри используются:

  • тип аргумента запроса;
  • тип успешного ответа;
  • тип ошибки;
  • тип meta;
  • тип transformResponse;
  • тип transformErrorResponse.

При ручной типизации количество интерфейсов быстро растёт:

interface User {}
interface UserList {}
interface CreateUserRequest {}
interface CreateUserResponse {}
interface UpdateUserRequest {}
interface ApiError {}

Генерация типов устраняет необходимость поддерживать всё вручную.


OpenAPI как основной источник схем

Наиболее распространённый подход — использование OpenAPI.

OpenAPI описывает:

  • маршруты;
  • параметры;
  • request body;
  • response body;
  • коды ответов;
  • security;
  • enum;
  • nullable-поля;
  • union-структуры;
  • вложенные объекты.

Пример 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

Одним из самых популярных инструментов является 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
    }
  }
}

Использование generated types в RTK Query

После генерации типов их можно подключить в 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 знает структуру ответа;
  • IDE поддерживает autocomplete;
  • ошибки структуры обнаруживаются на этапе компиляции.

Генерация endpoint-ов RTK Query

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

Автоматически сгенерированный API Slice

Результат генерации:

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

Типизация request body

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.


Nullable и optional поля

OpenAPI различает:

  • optional field;
  • nullable field.

Пример:

email:
  type: string
  nullable: true

Generated type:

email: string | null

Optional поле:

email:
  type: string

Результат:

email?: string

Разница критически важна:

// поле отсутствует
{}

// поле присутствует, но равно null
{
  email: null
}

Enum-типизация

Схема:

status:
  type: string
  enum:
    - pending
    - active
    - blocked

Generated type:

status: 'pending' | 'active' | 'blocked'

RTK Query начинает автоматически проверять допустимые значения:

user.status = 'deleted'
// ошибка TypeScript

Генерация union-типов

OpenAPI поддерживает:

  • oneOf;
  • anyOf;
  • allOf.

Пример:

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()
}

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

Ошибка — одна из самых сложных частей типизации.

Схема:

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
})

Типизация transformResponse

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
  })
})

При генерации типов важно разделять:

  • transport model;
  • domain model.

Domain Model vs DTO

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-моделей.


Генерация типов для GraphQL

RTK Query можно использовать вместе с GraphQL.

Популярный инструмент:

GraphQL Code Generator

Установка:

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

Использование GraphQL types в RTK Query

import {
  GetUsersQuery
} from './generated/graphql'

getUsers: build.query<GetUsersQuery, void>({
  query: () => ({
    document: GET_USERS_QUERY
  })
})

Типизация pagination

Схема:

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}`
})

Типизация query params

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
  })
})

Типизация path parameters

/users/{id}

Generated type:

type GetUserArg = {
  id: number
}

Endpoint:

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

Типизация headers

OpenAPI может описывать headers:

headers:
  X-Request-Id:
    schema:
      type: string

Типы можно использовать внутри custom baseQuery:

type HeadersResponse = {
  'x-request-id': string
}

Partial и patch-модели

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
  })
})

Типизация cache tags

Tags тоже можно типизировать.

tagTypes: ['User', 'Post'] as const

Либо:

type Tag = 'User' | 'Post'

RTK Query предотвращает ошибки:

providesTags: ['Users']
// ошибка

Генерация SDK поверх RTK Query

Некоторые команды генерируют полноценный 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 }
    }
  }
})

Runtime validation и generated types

TypeScript проверяет типы только во время компиляции.

Для runtime validation часто используются:

  • Zod
  • Valibot
  • io-ts
  • Yup

Пример с 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)
}

OpenAPI + Zod генерация

Существуют инструменты:

  • openapi-zod-client
  • openapi-typescript-codegen
  • orval

Они генерируют:

  • TypeScript types;
  • Zod schemas;
  • API clients;
  • React hooks;
  • RTK Query services.

Orval и RTK Query

Популярный генератор:

Orval

Конфигурация:

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

Проблемы рассинхронизации схем

Типичные проблемы:

  • backend изменил поле;
  • frontend не обновил types;
  • schema outdated;
  • nullable changed;
  • enum changed;
  • response wrapper changed.

Автогенерация решает проблему только при условии:

  • схема актуальна;
  • генерация выполняется регулярно;
  • CI проверяет типы.

Версионирование схем

Распространённый подход:

/api/v1
/api/v2

Для каждой версии:

openapi-v1.yaml
openapi-v2.yaml

Генерация:

src/generated/v1
src/generated/v2

Организация generated-кода

Типичная структура:

src/
├── api/
│   ├── generated/
│   │   ├── types.ts
│   │   ├── endpoints.ts
│   │   └── hooks.ts
│   │
│   ├── baseApi.ts
│   └── customApi.ts

Generated-файлы обычно:

  • не редактируются вручную;
  • исключаются из lint-правил;
  • пересоздаются автоматически.

Частичное расширение generated API

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

Generated API:

export const generatedApi = createApi(...)

Расширение:

export const extendedApi =
  generatedApi.injectEndpoints({
    endpoints: (build) => ({
      customEndpoint: build.query({
        query: () => '/custom'
      })
    })
  })

Это позволяет:

  • не менять generated code;
  • добавлять кастомную логику;
  • сохранять совместимость с регенерацией.

Проблемы large-scale генерации

В очень больших API возникают сложности:

  • десятки тысяч строк generated types;
  • медленная компиляция TypeScript;
  • рост памяти tsserver;
  • деградация autocomplete;
  • циклические типы.

Способы решения:

  • разбивать schema на модули;
  • генерировать по сервисам;
  • использовать project references;
  • избегать giant unions;
  • отключать unnecessary output.

Strict mode и generated types

Максимальную пользу генерация приносит только при:

{
  "strict": true
}

Особенно важны:

{
  "noImplicitAny": true,
  "strictNullChecks": true,
  "exactOptionalPropertyTypes": true
}

Без strictNullChecks теряется смысл nullable-типизации.


Типизация infinite queries

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.


Интеграция с monorepo

Часто схема и generated types располагаются в отдельном пакете:

packages/
├── api-schema/
├── api-types/
├── frontend/
└── backend/

Frontend импортирует:

import { User } from '@company/api-types'

Так достигается единый источник правды для всех приложений.