Skip to main content

RTK Query TypeScript Integration

RTK Query's killer feature is automatic type inference: define your endpoint response and argument types once, and RTK Query generates fully-typed hooks, mutation returns, and cache updates. Unlike SWR (manual per-hook typing), RTK Query's generics propagate through your entire API slice, eliminating entire categories of type errors. This guide covers defining typed endpoints, leveraging RTK Query's inference, and using advanced patterns like conditional types and discriminated unions for production APIs.

Defining Typed Endpoints

RTK Query's API is built on generics: builder.query<ResponseType, ArgType>() and builder.mutation<ResponseType, ArgType>(). Define these correctly once, and TypeScript handles the rest:

import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';

// Response types
interface User {
id: number;
name: string;
email: string;
role: 'admin' | 'user';
}

interface Post {
id: number;
title: string;
content: string;
authorId: number;
createdAt: string;
}

interface CreatePostRequest {
title: string;
content: string;
}

// API definition
export const api = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({ baseUrl: 'https://api.example.com' }),
tagTypes: ['User', 'Post'],
endpoints: (builder) => ({
// Query: <ResponseType, ArgType>
getUser: builder.query<User, number>({
query: (userId) => `/users/${userId}`,
providesTags: (result, error, id) => [{ type: 'User', id }],
}),

getPosts: builder.query<Post[], void>({
query: () => '/posts',
providesTags: ['Post'],
}),

searchPosts: builder.query<Post[], { query: string; limit?: number }>({
query: (params) => ({
url: '/posts/search',
params,
}),
providesTags: ['Post'],
}),

// Mutation: <ResponseType, ArgType>
createPost: builder.mutation<Post, CreatePostRequest>({
query: (newPost) => ({
url: '/posts',
method: 'POST',
body: newPost,
}),
invalidatesTags: ['Post'],
}),

updatePost: builder.mutation<Post, { id: number; title?: string; content?: string }>({
query: ({ id, ...patch }) => ({
url: `/posts/${id}`,
method: 'PATCH',
body: patch,
}),
invalidatesTags: (result, error, { id }) => [{ type: 'Post', id }],
}),
}),
});

// Auto-generated hooks with full type inference
export const {
useGetUserQuery,
useGetPostsQuery,
useSearchPostsQuery,
useCreatePostMutation,
useUpdatePostMutation,
} = api;

Now, every hook is fully typed:

// TypeScript infers:
// - data: User | undefined
// - error: Error | undefined
const { data, error, isLoading } = useGetUserQuery(123);

// TypeScript enforces:
// - arg must be a number
// - triggers error if arg is wrong type
useGetUserQuery('not a number'); // ✗ Type error

// For mutations:
// - trigger accepts CreatePostRequest shape
// - returns Post
const [createPost] = useCreatePostMutation();
const post = await createPost({ title: '...', content: '...' }).unwrap();
// post: Post

Automatic Hook Return Types

RTK Query auto-generates hook signatures. The return type varies by endpoint type:

Query hook:

function useGetUserQuery(arg: number, options?: UseQueryOptions): UseQueryResult<User>;

Properties:

  • data: User | undefined
  • isLoading: boolean (true during initial fetch)
  • isError: boolean
  • error: Error | undefined
  • isFetching: boolean (true during any fetch, including refetch)
  • status: 'pending' | 'fulfilled' | 'rejected'
  • refetch: () => void

Mutation hook:

function useCreatePostMutation(options?: UseMutationOptions): [trigger, result];

Returns:

  • trigger: (arg: CreatePostRequest) => Promise<{ data: Post } | { error: Error }>
  • result: { isLoading, error, data, ...}

Conditional Query Types with skipToken

Skip a query conditionally while maintaining type safety:

import { skipToken } from '@reduxjs/toolkit/query/react';

export function UserDetail({ userId }: { userId?: number }) {
const { data, isLoading } = useGetUserQuery(userId || skipToken);
// If userId is undefined, the query is skipped and data remains undefined

if (!userId) return <div>No user selected</div>;
if (isLoading) return <div>Loading...</div>;

return <div>{data?.name}</div>;
}

skipToken is a sentinel value that tells RTK Query not to execute the query. TypeScript knows that if you use skipToken, the result will be undefined.

Advanced: Base Query with Authentication

Type your base query for consistent authentication headers across all endpoints:

interface AuthState {
token?: string;
}

const baseQuery = fetchBaseQuery({
baseUrl: 'https://api.example.com',
prepareHeaders: (headers, { getState }) => {
const token = (getState() as { auth: AuthState }).auth.token;
if (token) {
headers.set('authorization', `Bearer ${token}`);
}
return headers;
},
});

export const api = createApi({
reducerPath: 'api',
baseQuery,
endpoints: (builder) => ({
// All endpoints inherit the auth headers automatically
getUser: builder.query<User, number>({
query: (id) => `/users/${id}`,
}),
}),
});

Handling Discriminated Union Responses

Some APIs return discriminated unions (success vs. error). Type them properly:

interface SuccessResponse<T> {
success: true;
data: T;
}

interface ErrorResponse {
success: false;
error: string;
}

type ApiResponse<T> = SuccessResponse<T> | ErrorResponse;

export const api = createApi({
// ...
endpoints: (builder) => ({
getUser: builder.query<ApiResponse<User>, number>({
query: (id) => `/users/${id}`,
}),
}),
});

export function UserComponent({ userId }: { userId: number }) {
const { data } = useGetUserQuery(userId);

if (!data) return <div>Loading...</div>;

if (!data.success) {
return <div>Error: {data.error}</div>;
}

// TypeScript narrows to SuccessResponse<User>
return <div>{data.data.name}</div>;
}

Typed Cache Updates in onQueryStarted

Apply type-safe cache updates using RTK Query's utilities:

const api = createApi({
// ...
endpoints: (builder) => ({
updatePost: builder.mutation<Post, { id: number; title: string }>({
query: ({ id, title }) => ({
url: `/posts/${id}`,
method: 'PATCH',
body: { title },
}),
async onQueryStarted(
{ id, title },
{ dispatch, queryFulfilled }
) {
// Type-safe cache update
const patchResult = dispatch(
api.util.updateQueryData(
'getPost', // Must match an endpoint name
id, // Must match the arg type (number)
(draft) => {
draft.title = title; // draft is typed as Post
}
)
);

try {
await queryFulfilled;
} catch {
patchResult.undo();
}
},
invalidatesTags: (result) => [
{ type: 'Post', id: result?.id },
],
}),
}),
});

TypeScript enforces that:

  • Endpoint name matches an existing query
  • Arguments match the endpoint's ArgType
  • The draft in the updater function is typed as the endpoint's ResponseType

Exporting Types for External Use

Export types from your API for use in other files:

// api.ts
export const api = createApi({ /* ... */ });

export type GetUserQuery = ReturnType<typeof useGetUserQuery>;
export type CreatePostMutation = ReturnType<typeof useCreatePostMutation>;

// In another file:
import type { GetUserQuery } from './api';

const result: GetUserQuery = useGetUserQuery(1);

Or extract types from the hook directly:

import { useGetUserQuery } from './api';

type UserData = ReturnType<typeof useGetUserQuery>['data'];
// UserData = User | undefined

Comparing TypeScript Support: SWR vs RTK Query

AspectSWRRTK Query
Type inferenceManual per-hookAutomatic from endpoint
Hook return typeExplicit generic <Data, Error>Auto-inferred from endpoint
Cache updatesManual typing neededAutomatic draft typing
Argument typingManual in mutation hookEnforced at endpoint definition
Error handlingCustom error typesBuilt-in error shape
Learning curveLower (familiar generics)Moderate (endpoint-centric)
Refactor-safetyManual updates requiredCompile-time enforcement

RTK Query's endpoint-first approach catches type errors at definition time, not component time.

Real-World Example: Fully Typed Blog API

// types.ts
export interface User {
id: number;
name: string;
email: string;
}

export interface Post {
id: number;
title: string;
content: string;
authorId: number;
published: boolean;
}

export interface CreatePostInput {
title: string;
content: string;
}

export interface UpdatePostInput {
id: number;
title?: string;
content?: string;
published?: boolean;
}

// api.ts
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
import type { User, Post, CreatePostInput, UpdatePostInput } from './types';

export const api = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({ baseUrl: 'https://api.example.com' }),
tagTypes: ['User', 'Post'],
endpoints: (builder) => ({
getUser: builder.query<User, number>({
query: (id) => `/users/${id}`,
providesTags: (result) => [{ type: 'User', id: result?.id }],
}),

listPosts: builder.query<Post[], void>({
query: () => '/posts',
providesTags: ['Post'],
}),

createPost: builder.mutation<Post, CreatePostInput>({
query: (newPost) => ({
url: '/posts',
method: 'POST',
body: newPost,
}),
invalidatesTags: ['Post'],
}),

updatePost: builder.mutation<Post, UpdatePostInput>({
query: ({ id, ...patch }) => ({
url: `/posts/${id}`,
method: 'PATCH',
body: patch,
}),
invalidatesTags: (result) => [
{ type: 'Post', id: result?.id },
'Post',
],
}),
}),
});

export const {
useGetUserQuery,
useListPostsQuery,
useCreatePostMutation,
useUpdatePostMutation,
} = api;

// Component.tsx: Everything is fully typed
export function BlogPage() {
const { data: posts } = useListPostsQuery();
const [createPost] = useCreatePostMutation();

async function handleCreate(input: CreatePostInput) {
try {
const newPost = await createPost(input).unwrap();
console.log('Created:', newPost.id); // newPost is typed as Post
} catch (error) {
console.error('Failed:', error);
}
}

return (
<div>
{posts?.map((post) => (
<div key={post.id}>{post.title}</div>
))}
</div>
);
}

Key Takeaways

  • RTK Query automatically infers types from builder.query<Response, Arg>() and builder.mutation<Response, Arg>()
  • Hooks are fully typed: argument validation, return type, data shape all checked at compile time
  • Use skipToken to conditionally skip queries while maintaining type safety
  • Cache updates in onQueryStarted are type-safe: endpoint names and arg types are enforced
  • Export types for external use or extract them from hook return types

Frequently Asked Questions

Can I use RTK Query with untyped APIs?

Yes, but you'll lose benefits. Type responses as any or unknown to move forward:

getUser: builder.query<any, number>({
query: (id) => `/users/${id}`,
}),

Not recommended for production.

How do I type error responses?

RTK Query captures HTTP errors in the error property. For custom error shapes, type the response itself:

interface ErrorPayload { message: string; }
interface SuccessPayload<T> { data: T; }

type UserResponse = SuccessPayload<User> | ErrorPayload;

getUser: builder.query<UserResponse, number>({ /* ... */ }),

Can I share types between my client and server?

Yes. If your server exports types (from an npm package or shared repo), import them directly:

import type { User, Post } from '@mycompany/api-types';

getUser: builder.query<User, number>({ /* ... */ }),

This ensures client and server types stay in sync.

How do I handle pagination with types?

Define a paginated response type:

interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
pageSize: number;
}

listPosts: builder.query<PaginatedResponse<Post>, { page: number; limit: number }>({
query: ({ page, limit }) => `/posts?page=${page}&limit=${limit}`,
}),

Can I extend RTK Query's hooks with custom logic?

Yes, wrap the auto-generated hooks:

export function useGetUserSafe(id: number | undefined) {
const result = useGetUserQuery(id || skipToken);
return {
...result,
isReady: !result.isLoading && result.data !== undefined,
};
}

Further Reading