Skip to content

Commit 455b735

Browse files
authored
fix(openapi-react-query): Fix typing of queryOptions (#1952)
- Adds `NoInfer` to function return type because starting from TypeScript 5.5 it can affect inference. For example: const f = <T,>(x: NoInfer<T>) => x; const x: number = f('foo'); ^^^^^ expects number Previous versions could not infer `T` and complained about something like "unknown cannot be assigned to number". This error is not reproducible with `paths` so we test against a minimal paths type. - Excludes `SkipToken` (introduced in v5.25) from `queryFn` to be compatible with `useSuspenseQuery`.
1 parent 3988612 commit 455b735

File tree

5 files changed

+111
-13
lines changed

5 files changed

+111
-13
lines changed

.changeset/query-options-infer.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"openapi-react-query": patch
3+
---
4+
5+
Fix return type inference for `queryOptions()` when used inside `useQuery` or `useSuspenseQuery`.

.changeset/query-options-queryfn.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"openapi-react-query": patch
3+
---
4+
5+
Narrow `queryFn` returned by `queryOptions()` to be a function.

packages/openapi-react-query/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@
7979
"react-error-boundary": "^4.1.2"
8080
},
8181
"peerDependencies": {
82-
"@tanstack/react-query": "^5.0.0",
82+
"@tanstack/react-query": "^5.25.0",
8383
"openapi-fetch": "workspace:^"
8484
}
8585
}

packages/openapi-react-query/src/index.ts

+13-6
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
type UseSuspenseQueryResult,
88
type QueryClient,
99
type QueryFunctionContext,
10+
type SkipToken,
1011
useMutation,
1112
useQuery,
1213
useSuspenseQuery,
@@ -37,7 +38,17 @@ export type QueryOptionsFunction<Paths extends Record<string, Record<HttpMethod,
3738
...[init, options]: RequiredKeysOf<Init> extends never
3839
? [InitWithUnknowns<Init>?, Options?]
3940
: [InitWithUnknowns<Init>, Options?]
40-
) => UseQueryOptions<Response["data"], Response["error"], Response["data"], QueryKey<Paths, Method, Path>>;
41+
) => NoInfer<
42+
Omit<
43+
UseQueryOptions<Response["data"], Response["error"], Response["data"], QueryKey<Paths, Method, Path>>,
44+
"queryFn"
45+
> & {
46+
queryFn: Exclude<
47+
UseQueryOptions<Response["data"], Response["error"], Response["data"], QueryKey<Paths, Method, Path>>["queryFn"],
48+
SkipToken | undefined
49+
>;
50+
}
51+
>;
4152

4253
export type UseQueryMethod<Paths extends Record<string, Record<HttpMethod, {}>>, Media extends MediaType> = <
4354
Method extends HttpMethod,
@@ -121,11 +132,7 @@ export default function createClient<Paths extends {}, Media extends MediaType =
121132
useQuery: (method, path, ...[init, options, queryClient]) =>
122133
useQuery(queryOptions(method, path, init as InitWithUnknowns<typeof init>, options), queryClient),
123134
useSuspenseQuery: (method, path, ...[init, options, queryClient]) =>
124-
useSuspenseQuery(
125-
// @ts-expect-error TODO: fix minor type mismatch between useQuery and useSuspenseQuery
126-
queryOptions(method, path, init as InitWithUnknowns<typeof init>, options),
127-
queryClient,
128-
),
135+
useSuspenseQuery(queryOptions(method, path, init as InitWithUnknowns<typeof init>, options), queryClient),
129136
useMutation: (method, path, options, queryClient) =>
130137
useMutation(
131138
{

packages/openapi-react-query/test/index.test.tsx

+87-6
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,39 @@ import type { paths } from "./fixtures/api.js";
44
import createClient from "../src/index.js";
55
import createFetchClient from "openapi-fetch";
66
import { fireEvent, render, renderHook, screen, waitFor, act } from "@testing-library/react";
7-
import { QueryClient, QueryClientProvider, useQueries } from "@tanstack/react-query";
7+
import {
8+
QueryClient,
9+
QueryClientProvider,
10+
useQueries,
11+
useQuery,
12+
useSuspenseQuery,
13+
skipToken,
14+
} from "@tanstack/react-query";
815
import { Suspense, type ReactNode } from "react";
916
import { ErrorBoundary } from "react-error-boundary";
1017

18+
type minimalGetPaths = {
19+
// Without parameters.
20+
"/foo": {
21+
get: {
22+
responses: {
23+
200: { content: { "application/json": true } };
24+
500: { content: { "application/json": false } };
25+
};
26+
};
27+
};
28+
// With some parameters (makes init required) and different responses.
29+
"/bar": {
30+
get: {
31+
parameters: { query: {} };
32+
responses: {
33+
200: { content: { "application/json": "bar 200" } };
34+
500: { content: { "application/json": "bar 500" } };
35+
};
36+
};
37+
};
38+
};
39+
1140
const queryClient = new QueryClient({
1241
defaultOptions: {
1342
queries: {
@@ -27,9 +56,7 @@ const fetchInfinite = async () => {
2756

2857
beforeAll(() => {
2958
server.listen({
30-
onUnhandledRequest: (request) => {
31-
throw new Error(`No request handler found for ${request.method} ${request.url}`);
32-
},
59+
onUnhandledRequest: "error",
3360
});
3461
});
3562

@@ -96,7 +123,7 @@ describe("client", () => {
96123
expect(data).toEqual(response);
97124
});
98125

99-
it("returns query options that can be passed to useQueries and have correct types inferred", async () => {
126+
it("returns query options that can be passed to useQueries", async () => {
100127
const fetchClient = createFetchClient<paths>({ baseUrl, fetch: fetchInfinite });
101128
const client = createClient(fetchClient);
102129

@@ -150,6 +177,60 @@ describe("client", () => {
150177
// Generated different queryKey for each query.
151178
expect(queryClient.isFetching()).toBe(4);
152179
});
180+
181+
it("returns query options that can be passed to useQuery", async () => {
182+
const SKIP = { queryKey: [] as any, queryFn: skipToken } as const;
183+
184+
const fetchClient = createFetchClient<minimalGetPaths>({ baseUrl });
185+
const client = createClient(fetchClient);
186+
187+
const { result } = renderHook(
188+
() =>
189+
useQuery(
190+
// biome-ignore lint/correctness/noConstantCondition: it's just here to test types
191+
false
192+
? {
193+
...client.queryOptions("get", "/foo"),
194+
select: (data) => {
195+
expectTypeOf(data).toEqualTypeOf<true>();
196+
197+
return "select(true)" as const;
198+
},
199+
}
200+
: SKIP,
201+
),
202+
{ wrapper },
203+
);
204+
205+
expectTypeOf(result.current.data).toEqualTypeOf<"select(true)" | undefined>();
206+
expectTypeOf(result.current.error).toEqualTypeOf<false | null>();
207+
});
208+
209+
it("returns query options that can be passed to useSuspenseQuery", async () => {
210+
const fetchClient = createFetchClient<minimalGetPaths>({
211+
baseUrl,
212+
fetch: () => Promise.resolve(Response.json(true)),
213+
});
214+
const client = createClient(fetchClient);
215+
216+
const { result } = renderHook(
217+
() =>
218+
useSuspenseQuery({
219+
...client.queryOptions("get", "/foo"),
220+
select: (data) => {
221+
expectTypeOf(data).toEqualTypeOf<true>();
222+
223+
return "select(true)" as const;
224+
},
225+
}),
226+
{ wrapper },
227+
);
228+
229+
await waitFor(() => expect(result.current).not.toBeNull());
230+
231+
expectTypeOf(result.current.data).toEqualTypeOf<"select(true)">();
232+
expectTypeOf(result.current.error).toEqualTypeOf<false | null>();
233+
});
153234
});
154235

155236
describe("useQuery", () => {
@@ -203,7 +284,7 @@ describe("client", () => {
203284
});
204285

205286
it("should infer correct data and error type", async () => {
206-
const fetchClient = createFetchClient<paths>({ baseUrl });
287+
const fetchClient = createFetchClient<paths>({ baseUrl, fetch: fetchInfinite });
207288
const client = createClient(fetchClient);
208289

209290
const { result } = renderHook(() => client.useQuery("get", "/string-array"), {

0 commit comments

Comments
 (0)