Search code examples
typescriptreact-querytanstackreact-query

Infer TError useQuery based on the queryFn return type


I am fetching some data in React with useQuery from tanstack. This hook returns UseQueryResult<TData, TError> and tanstack nicely infers TData from the queryFn making the fetch, but I can't make Typescript infer TError from the same function.

For more context:

Any errors returned from our backend respect the problem details spec, so the http client in the frontend parses api responses and returns IApiResponse with a valid IProblemDetails interface when a request fails.

export type IApiResponse<T> = BaseResponse &
  (
    | {
        isError: false;
        data: T;
      }
    | {
        isError: true;
        problem: IProblemDetails;
        rawBody: unknown;
      }
  );

So I know the folowing function would either return data of T or IProblemDetails

export async function getData({resourceId}: {resourceId: string}) {
  const res = await new ApiWithAuth().get<T>(
    `api/resource/${resourceId}`
  );

  if (res.isError) {
    return Promise.reject(res.problem);
  }

  return res.data;
}

For a query like this

 const playlistQuery = useQuery(['get-user-playlist-by-id'], async () => {
    const res = await getUserPlaylistById({ userId: userId!, playlistId });

    if (res.isError) {
      throw res.problem;
    }

    return res.data;
  });

The result is typed like playlistQuery: UseQueryResult<UserPlaylist, unknown>; it correctly knows the data returned, but not the error type when it fails. Both me and Typescript know that problem is IProblemDetails when I throw res.problem so why can't useQuery infer TError from there?

From these docs I understand UseQueryResult.error is populated and typed based on the promise rejected/error thrown in the queryFn call so I would expect playlistQuery: IProblemDetails to be inferred like TData is inferred to be UserPlaylist

I looked at this question and this blog , but I am not using an error boundry or triggering a side effect with onError. Instead, different components of my page need to react locally to an error state, so I want a way for Typescript to know that when playlistQuery.isError then playlistQuery.error will hold an IProblemDetails object.

How to achieve this without passing generics to useQuery or asserting the type? (both would be cumbersome and not typesafe)


Solution

  • TypeScript does know problem is IProblemDetails, but reject accepts any argument:

    reject<T = never>(reason?: any): Promise<T>;
    

    As explained nicely here, anything could happen inside your function in Javascript.
    Even if you didn't throw any error, system exceptions like RangeError: Maximum call stack size exceeded could happen. There is no guarantee to get an exception of a certain type.

    Therefore, it's impossible to know this during compilation, and it's also what TypeScript does as default (useUnknownInCatchVariables):

    try {
      throw new AxiosError();
    } catch (error: AxiosError) { // ❌ TS1196: type must be any or unknown if specified.
      console.log(error.status)
    }
    

    The correct way is to narrow the type at runtime:

    try {
      throw new AxiosError();
    } catch (error) { // error: unknown
      if (isAxiosError(error)) { // ✅ Correct way
        console.log(error.status)
      }
    }
    

    There was a trick in v4 of react-query to set TError without setting TData explicitly in useQuery, using the onError method:

    export const useMyQuery = (payload: IPayload) => {
      const { data, error} = useQuery({
        queryKey: ['test'],
        queryFn: () => API.fetchMyData(payload),
        // This does the trick
        onError: (err: IApiError) => err,
      });
    };
    

    But unfortunately, it was removed in v5.
    Right now only useMutation have this property in v5.