Продвинутые типы для трансформаций

Механизм transformResponse используется для преобразования данных, полученных от сервера, перед сохранением результата в кэш RTK Query. В простых случаях достаточно указать тип возвращаемого значения, однако в сложных приложениях появляются дополнительные уровни типизации:

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

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

interface UserApi {
    id: number
    first_name: string
    last_name: string
}

interface User {
    id: number
    fullName: string
}

const api = createApi({
    reducerPath: 'api',
    baseQuery: fetchBaseQuery({
        baseUrl: '/api'
    }),
    endpoints: (builder) => ({
        getUsers: builder.query<User[], void>({
            query: () => '/users',

            transformResponse: (response: UserApi[]): User[] => {
                return response.map(user => ({
                    id: user.id,
                    fullName: `${user.first_name} ${user.last_name}`
                }))
            }
        })
    })
})

Тип результата transformResponse становится итоговым типом endpoint.


Типизация исходного ответа и результата

Сигнатура transformResponse:

transformResponse(
    baseQueryReturnValue,
    meta,
    arg
)

Полная типизация:

transformResponse: (
    response: ApiResponse,
    meta: FetchBaseQueryMeta | undefined,
    arg: QueryArg
) => ResultType

Пример:

interface ApiPost {
    id: number
    title: string
    created_at: string
}

interface Post {
    id: number
    title: string
    createdAt: Date
}

type GetPostsArg = {
    limit: number
}

getPosts: builder.query<Post[], GetPostsArg>({
    query: ({ limit }) => `/posts?limit=${limit}`,

    transformResponse: (
        response: ApiPost[],
        meta,
        arg
    ): Post[] => {

        console.log(meta)
        console.log(arg.limit)

        return response.map(post => ({
            id: post.id,
            title: post.title,
            createdAt: new Date(post.created_at)
        }))
    }
})

Типизация вложенных структур API

Большинство REST API возвращают не массив напрямую, а обёртку:

{
    "data": [],
    "meta": {},
    "success": true
}

Типизация:

interface ApiListResponse<T> {
    data: T[]
    meta: {
        total: number
        page: number
    }
    success: boolean
}

interface ApiUser {
    id: number
    email: string
}

interface User {
    id: number
    email: string
}

getUsers: builder.query<User[], void>({
    query: () => '/users',

    transformResponse: (
        response: ApiListResponse<ApiUser>
    ): User[] => {

        return response.data.map(user => ({
            id: user.id,
            email: user.email
        }))
    }
})

Generic-типы для универсальных трансформаций

Создание повторно используемых трансформеров:

interface ApiEntity {
    id: number
}

interface ApiListResponse<T> {
    data: T[]
}

function extractList<T>(
    response: ApiListResponse<T>
): T[] {
    return response.data
}

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

getUsers: builder.query<User[], void>({
    query: () => '/users',

    transformResponse: extractList<User>
})

Более сложный вариант:

function transformCollection<ApiType, ResultType>(
    response: ApiListResponse<ApiType>,
    mapper: (item: ApiType) => ResultType
): ResultType[] {

    return response.data.map(mapper)
}

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

transformResponse: (response: ApiListResponse<ApiUser>) => {
    return transformCollection(response, user => ({
        id: user.id,
        email: user.email
    }))
}

Типизация нормализованных данных

RTK Query часто используется вместе с createEntityAdapter.

Типизированная нормализация:

import {
    createEntityAdapter,
    EntityState
} from '@reduxjs/toolkit'

interface User {
    id: number
    name: string
}

const usersAdapter = createEntityAdapter<User>()

const initialState = usersAdapter.getInitialState()

type UsersState = EntityState<User, number>

getUsers: builder.query<UsersState, void>({
    query: () => '/users',

    transformResponse: (
        response: User[]
    ): UsersState => {

        return usersAdapter.setAll(
            initialState,
            response
        )
    }
})

Теперь endpoint возвращает:

{
    ids: number[]
    entities: Record<number, User>
}

Типизация вычисляемых полей

Трансформация может добавлять вычисляемые значения.

Пример:

interface ApiProduct {
    id: number
    price: number
    discount: number
}

interface Product {
    id: number
    price: number
    discount: number
    finalPrice: number
}

transformResponse: (
    response: ApiProduct[]
): Product[] => {

    return response.map(product => ({
        ...product,

        finalPrice:
            product.price -
            product.price * product.discount
    }))
}

Типизация преобразования дат

Сервер обычно возвращает даты строками.

Типизация:

interface ApiOrder {
    id: number
    created_at: string
}

interface Order {
    id: number
    createdAt: Date
}

transformResponse: (
    response: ApiOrder
): Order => {

    return {
        id: response.id,
        createdAt: new Date(response.created_at)
    }
}

Массовое преобразование:

type WithDates<T> = {
    [K in keyof T]:
        T[K] extends string
            ? Date | string
            : T[K]
}

Conditional Types в трансформациях

Условные типы позволяют создавать универсальные преобразования.

Пример:

type ApiResult<T> =
    T extends string
        ? { value: string }
        : T extends number
            ? { value: number }
            : never

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

function transformValue<T>(
    value: ApiResult<T>
): T {

    return value.value as T
}

Дискриминирующие объединения

Трансформации могут зависеть от типа объекта.

Пример API:

type ApiNotification =
    | {
        type: 'email'
        email: string
    }
    | {
        type: 'sms'
        phone: string
    }

Финальная модель:

type Notification =
    | {
        type: 'email'
        target: string
    }
    | {
        type: 'sms'
        target: string
    }

Трансформация:

transformResponse: (
    response: ApiNotification[]
): Notification[] => {

    return response.map(item => {

        switch (item.type) {

            case 'email':
                return {
                    type: 'email',
                    target: item.email
                }

            case 'sms':
                return {
                    type: 'sms',
                    target: item.phone
                }
        }
    })
}

TypeScript корректно сузит тип внутри switch.


Типизация nullable-структур

Многие API возвращают null.

Пример:

interface ApiProfile {
    id: number
    avatar: string | null
}

interface Profile {
    id: number
    avatarUrl?: string
}

Трансформация:

transformResponse: (
    response: ApiProfile
): Profile => {

    return {
        id: response.id,

        avatarUrl:
            response.avatar ?? undefined
    }
}

Типизация трансформаций с Partial

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

Пример:

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

Частичный ответ:

type PartialUser = Partial<User>

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

transformResponse: (
    response: PartialUser[]
): PartialUser[] => {

    return response.filter(user => user.id)
}

Глубокие generic-трансформации

Создание сложных универсальных преобразований:

interface ApiResponse<T> {
    data: T
}

type ExtractData<T> =
    T extends ApiResponse<infer U>
        ? U
        : never

Пример:

type UserData =
    ExtractData<ApiResponse<User>>

Результат:

type UserData = User

Типизация transformErrorResponse

RTK Query позволяет типизировать преобразование ошибок.

Пример:

interface ApiError {
    status: string
    message: string
}

interface AppError {
    code: string
    text: string
}

Трансформация:

transformErrorResponse: (
    response: ApiError
): AppError => {

    return {
        code: response.status,
        text: response.message
    }
}

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

const { error } = useGetUsersQuery()

if (error) {
    console.log(error.code)
}

Типизация meta-информации

fetchBaseQuery может возвращать meta.

Пример:

transformResponse: (
    response: User[],
    meta
): User[] => {

    console.log(meta?.request)
    console.log(meta?.response)

    return response
}

Тип meta:

FetchBaseQueryMeta

Трансформация с объединением нескольких структур

Пример сложного API:

{
    "users": [],
    "roles": []
}

Типизация:

interface ApiUser {
    id: number
    role_id: number
}

interface ApiRole {
    id: number
    name: string
}

interface ApiResponse {
    users: ApiUser[]
    roles: ApiRole[]
}

Финальная модель:

interface User {
    id: number
    role: string
}

Трансформация:

transformResponse: (
    response: ApiResponse
): User[] => {

    return response.users.map(user => {

        const role = response.roles.find(
            role => role.id === user.role_id
        )

        return {
            id: user.id,
            role: role?.name ?? 'unknown'
        }
    })
}

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

Pick

type UserPreview =
    Pick<User, 'id' | 'name'>

Omit

type SafeUser =
    Omit<User, 'password'>

Record

type UsersMap =
    Record<number, User>

Readonly

type ImmutableUser =
    Readonly<User>

Типизация массивов с разными структурами

API может возвращать смешанные коллекции.

Пример:

type ApiFeedItem =
    | ApiPost
    | ApiComment

Трансформация:

type FeedItem =
    | Post
    | Comment

transformResponse: (
    response: ApiFeedItem[]
): FeedItem[] => {

    return response.map(item => {

        if ('title' in item) {
            return {
                id: item.id,
                title: item.title
            }
        }

        return {
            id: item.id,
            text: item.text
        }
    })
}

Асинхронные трансформации

transformResponse должен быть синхронным. Возврат Promise не поддерживается.

Некорректно:

transformResponse: async (response) => {
    return await process(response)
}

Корректный подход:

queryFn: async () => {
    const response = await fetch('/users')
    const data = await response.json()

    return {
        data: transformUsers(data)
    }
}

Выведение типов через ReturnType

Пример:

function mapUser(user: ApiUser) {
    return {
        id: user.id,
        fullName:
            `${user.first_name} ${user.last_name}`
    }
}

type User =
    ReturnType<typeof mapUser>

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

transformResponse: (
    response: ApiUser[]
): User[] => {

    return response.map(mapUser)
}

Выведение типов через typeof

Пример:

const defaultUser = {
    id: 0,
    name: ''
}

type User = typeof defaultUser

Generic helper для трансформаций

Создание универсального helper:

function createTransformer<Api, Result>(
    mapper: (value: Api) => Result
) {

    return (response: Api[]): Result[] => {
        return response.map(mapper)
    }
}

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

const transformUsers =
    createTransformer<ApiUser, User>(
        user => ({
            id: user.id,
            fullName:
                `${user.first_name} ${user.last_name}`
        })
    )

Применение:

transformResponse: transformUsers

Типизация deeply nested API

Сложные ответы:

interface ApiResponse {
    data: {
        users: {
            items: ApiUser[]
        }
    }
}

Трансформация:

transformResponse: (
    response: ApiResponse
): User[] => {

    return response
        .data
        .users
        .items
        .map(user => ({
            id: user.id,
            name: user.name
        }))
}

Типизация immutable-структур

Пример:

type ImmutableUsers =
    ReadonlyArray<
        Readonly<User>
    >

Трансформация:

transformResponse: (
    response: User[]
): ImmutableUsers => {

    return response
}

Сужение типов через type predicates

Создание type guard:

function isAdmin(
    user: User | Admin
): user is Admin {

    return 'permissions' in user
}

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

transformResponse: (
    response: Array<User | Admin>
): Admin[] => {

    return response.filter(isAdmin)
}

TypeScript автоматически выведет Admin[].


Композиция трансформаций

Разделение преобразований:

const normalizeUser = (
    user: ApiUser
): User => {

    return {
        id: user.id,
        name: user.name.trim()
    }
}

Дополнительная стадия:

const sortUsers = (
    users: User[]
): User[] => {

    return users.sort(
        (a, b) =>
            a.name.localeCompare(b.name)
    )
}

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

transformResponse: (
    response: ApiUser[]
): User[] => {

    return sortUsers(
        response.map(normalizeUser)
    )
}

Типизация трансформаций для infinite scroll

Пример:

interface ApiPage<T> {
    items: T[]
    nextCursor: string | null
}

Финальный тип:

interface PageResult<T> {
    data: T[]
    hasMore: boolean
}

Трансформация:

transformResponse: (
    response: ApiPage<User>
): PageResult<User> => {

    return {
        data: response.items,
        hasMore: response.nextCursor !== null
    }
}

Типизация через mapped types

Пример:

type Nullable<T> = {
    [K in keyof T]:
        T[K] | null
}

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

type NullableUser =
    Nullable<User>

Ошибки при типизации трансформаций

Несоответствие результата endpoint

Некорректно:

builder.query<User[], void>({
    transformResponse: (
        response: ApiUser[]
    ): User => {

        return {
            id: 1,
            name: 'Test'
        }
    }
})

Endpoint ожидает User[], а возвращается User.


Потеря generic-типов

Некорректно:

function extract(response: any) {
    return response.data
}

Правильно:

function extract<T>(
    response: ApiResponse<T>
): T {

    return response.data
}

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

Плохой пример:

transformResponse: (
    response: any
): any => {
    return response
}

Последствия:

  • отключение проверки типов;
  • потеря автодополнения;
  • невозможность безопасного рефакторинга;
  • скрытые runtime-ошибки.

Архитектура типизированных трансформаций

Крупные проекты обычно разделяют:

  • API-модели;
  • доменные модели;
  • mapper-функции;
  • generic helper;
  • utility types;
  • normalizer;
  • validators;
  • type guards.

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

shared/
    api/
    mappers/
    transformers/
    types/
    adapters/

Пример mapper-файла:

export function mapUser(
    user: ApiUser
): User {

    return {
        id: user.id,
        name: user.name
    }
}

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

transformResponse: (
    response: ApiUser[]
): User[] => {

    return response.map(mapUser)
}