Fixing RTK Query BaseQuery type mismatches in token refresh flow

Summary

A custom base query in RTK Query that handles token refresh was added without correctly typing the return value of the inner refresh request. This caused TypeScript to fall back to unknown, leading to runtime errors and confusing editor tooling.

Root Cause

  • The refreshResult variable was typed as any because the generic BaseQueryFn was instantiated with unknown for the success type.
  • The inner call to baseQuery does not inherit the response schema of the refresh endpoint, so TypeScript cannot infer RefreshTokenResponse.
  • The mutation definition later in the slice also lacks an explicit transformResponse type, so the mismatch propagates.

Why This Happens in Real Systems

  • Generic base queries often use unknown for simplicity, but when they wrap other queries they must propagate the concrete response type.
  • Engineers copy‑paste boilerplate and forget to override the generic parameters for each specialized use case.
  • In large codebases, the type‑holes are hidden by any casts (as RefreshTokenResponse) that silence the compiler.

Real-World Impact

  • Runtime crashes when refreshResult.data is accessed on an object that doesn’t match the expected shape.
  • IDE autocompletion stops working, increasing development friction.
  • Unit tests that mock the refresh endpoint fail silently, masking bugs until production.

Example or Code (if necessary and relevant)

import { BaseQueryFn, fetchBaseQuery, FetchBaseQueryError } from '@reduxjs/toolkit/query/react';
import type { RefreshTokenResponse, IApiResponse } from './types';
import { setNewAccessToken, logOut } from '../slices/authSlice';
import type { RootState } from '../store';

type RefreshResult = IApiResponse;

const baseQuery = fetchBaseQuery({ baseUrl: '/api' });

const baseQueryWithReauth: BaseQueryFn Propagate the concrete success type for the inner refresh call
  RefreshResult
> = async (args, api, extraOptions) => {
  let result = await baseQuery(args, api, extraOptions);
  const refreshToken = (api.getState() as RootState).auth.refreshToken;

  if (result.error?.status === 401 && refreshToken) {
    const refreshResult = await baseQuery(
      {
        url: '/auth/refresh',
        method: 'POST',
        body: { refreshToken },
      },
      api,
      extraOptions,
    ) as { data?: RefreshResult };

    if (refreshResult?.data) {
      api.dispatch(setNewAccessToken(refreshResult.data as RefreshTokenResponse));
      result = await baseQuery(args, api, extraOptions);
    } else {
      console.error('Failed to fetch new access token:', refreshResult?.error);
      api.dispatch(logOut());
    }
  }

  return result;
};

How Senior Engineers Fix It

  • Explicitly type the generic parameters of BaseQueryFn to include the expected success schema (RefreshResult).
  • Use type‑guards or isRejectWithValue checks instead of raw as casts.
  • Centralize the refresh logic in a utility that returns a typed Promise<RefreshResult> and reuse it.
  • Add unit tests that mock both successful and failed refresh flows, asserting the inferred types.
  • Enable noImplicitAny and strictNullChecks in tsconfig.json to catch missing typings early.

Why Juniors Miss It

  • They rely on implicit any and assume TypeScript will “just work”.
  • Lack of familiarity with generic overloads in RTK Query’s BaseQueryFn.
  • Tendency to quick‑fix with as casts, which silences the compiler but hides the real problem.
  • Often focus on getting the feature to run rather than maintaining type safety across nested async calls.

Leave a Comment