From 97ec99f6712453f1c053876b91f25feac2f3c5c2 Mon Sep 17 00:00:00 2001 From: Isaac Riehm <32783392+isaacriehm@users.noreply.github.com> Date: Sun, 6 Sep 2026 10:13:45 -0700 Subject: [PATCH] fix(openapi-react-query): reject undeclared query and path parameters Init was inferred from the init argument, so the init parameter type was derived from the argument itself and no property could be excess. The operation's shape only participated as a constraint, which is ordinary assignability. Contextually type init by the operation's own init instead. parseAs is the only part of init the response type reads, so it is inferred on its own type parameter rather than by inferring the whole init. --- .changeset/exact-query-params.md | 5 ++ packages/openapi-react-query/src/index.ts | 53 ++++++++++++------- .../openapi-react-query/test/index.test.tsx | 15 ++++++ 3 files changed, 54 insertions(+), 19 deletions(-) create mode 100644 .changeset/exact-query-params.md diff --git a/.changeset/exact-query-params.md b/.changeset/exact-query-params.md new file mode 100644 index 000000000..4548c1fc9 --- /dev/null +++ b/.changeset/exact-query-params.md @@ -0,0 +1,5 @@ +--- +"openapi-react-query": patch +--- + +fix(openapi-react-query): reject query and path parameters the operation does not declare diff --git a/packages/openapi-react-query/src/index.ts b/packages/openapi-react-query/src/index.ts index fb9683164..73ebd0dfa 100644 --- a/packages/openapi-react-query/src/index.ts +++ b/packages/openapi-react-query/src/index.ts @@ -23,6 +23,7 @@ import type { Client as FetchClient, FetchResponse, MaybeOptionalInit, + ParseAs, } from "openapi-fetch"; import type { HttpMethod, MediaType, PathsWithMethod, RequiredKeysOf } from "openapi-typescript-helpers"; @@ -31,6 +32,20 @@ type InferSelectReturnType = TSelect extends (data: TData) => in type InitWithUnknowns = Init & { [key: string]: unknown }; +// The init argument is contextually typed by the operation's own init rather than by whatever the +// caller passed, so an undeclared query or path parameter is reported as an excess property. +// `parseAs` is the only part of init the response type depends on, so it is inferred by itself. +// Inferring the whole init is what loses the check. +type ExactInit< + Paths extends Record>, + Method extends HttpMethod, + Path extends PathsWithMethod, + ParseAsOpt, +> = InitWithUnknowns & { parseAs?: ParseAsOpt }>; + +// `never` is the no-`parseAs`-passed case, which must not satisfy ParseAsResponse's check. +type ParseAsInit = [ParseAsOpt] extends [never] ? unknown : { parseAs: ParseAsOpt }; + export type QueryKey< Paths extends Record>, Method extends HttpMethod, @@ -41,8 +56,7 @@ export type QueryKey< export type QueryOptionsFunction>, Media extends MediaType> = < Method extends HttpMethod, Path extends PathsWithMethod, - Init extends MaybeOptionalInit, - Response extends Required>, // note: Required is used to avoid repeating NonNullable in UseQuery types + Response extends Required, Media>>, // note: Required is used to avoid repeating NonNullable in UseQuery types Options extends Omit< UseQueryOptions< Response["data"], @@ -52,12 +66,13 @@ export type QueryOptionsFunction, "queryKey" | "queryFn" >, + ParseAsOpt extends ParseAs = never, >( method: Method, path: Path, - ...[init, options]: RequiredKeysOf extends never - ? [InitWithUnknowns?, Options?] - : [InitWithUnknowns, Options?] + ...[init, options]: RequiredKeysOf> extends never + ? [ExactInit?, Options?] + : [ExactInit, Options?] ) => NoInfer< Omit< UseQueryOptions< @@ -84,8 +99,7 @@ export type QueryOptionsFunction>, Media extends MediaType> = < Method extends HttpMethod, Path extends PathsWithMethod, - Init extends MaybeOptionalInit, - Response extends Required>, // note: Required is used to avoid repeating NonNullable in UseQuery types + Response extends Required, Media>>, // note: Required is used to avoid repeating NonNullable in UseQuery types Options extends Omit< UseQueryOptions< Response["data"], @@ -95,19 +109,19 @@ export type UseQueryMethod>, >, "queryKey" | "queryFn" >, + ParseAsOpt extends ParseAs = never, >( method: Method, url: Path, - ...[init, options, queryClient]: RequiredKeysOf extends never - ? [InitWithUnknowns?, Options?, QueryClient?] - : [InitWithUnknowns, Options?, QueryClient?] + ...[init, options, queryClient]: RequiredKeysOf> extends never + ? [ExactInit?, Options?, QueryClient?] + : [ExactInit, Options?, QueryClient?] ) => UseQueryResult, Response["error"]>; export type UseInfiniteQueryMethod>, Media extends MediaType> = < Method extends HttpMethod, Path extends PathsWithMethod, - Init extends MaybeOptionalInit, - Response extends Required>, + Response extends Required, Media>>, Options extends Omit< UseInfiniteQueryOptions< Response["data"], @@ -120,10 +134,11 @@ export type UseInfiniteQueryMethod & { pageParamName?: string; }, + ParseAsOpt extends ParseAs = never, >( method: Method, url: Path, - init: InitWithUnknowns, + init: ExactInit, options: Options, queryClient?: QueryClient, ) => UseInfiniteQueryResult< @@ -134,8 +149,7 @@ export type UseInfiniteQueryMethod>, Media extends MediaType> = < Method extends HttpMethod, Path extends PathsWithMethod, - Init extends MaybeOptionalInit, - Response extends Required>, // note: Required is used to avoid repeating NonNullable in UseQuery types + Response extends Required, Media>>, // note: Required is used to avoid repeating NonNullable in UseQuery types Options extends Omit< UseSuspenseQueryOptions< Response["data"], @@ -145,12 +159,13 @@ export type UseSuspenseQueryMethod, "queryKey" | "queryFn" >, + ParseAsOpt extends ParseAs = never, >( method: Method, url: Path, - ...[init, options, queryClient]: RequiredKeysOf extends never - ? [InitWithUnknowns?, Options?, QueryClient?] - : [InitWithUnknowns, Options?, QueryClient?] + ...[init, options, queryClient]: RequiredKeysOf> extends never + ? [ExactInit?, Options?, QueryClient?] + : [ExactInit, Options?, QueryClient?] ) => UseSuspenseQueryResult, Response["error"]>; export type UseMutationMethod>, Media extends MediaType> = < @@ -228,7 +243,7 @@ export default function createClient, options), queryClient), useInfiniteQuery: (method, path, init, options, queryClient) => { const { pageParamName = "cursor", ...restOptions } = options; - const { queryKey } = queryOptions(method, path, init); + const { queryKey } = queryOptions(method, path, init as InitWithUnknowns); return useInfiniteQuery( { queryKey, diff --git a/packages/openapi-react-query/test/index.test.tsx b/packages/openapi-react-query/test/index.test.tsx index 78715e643..af1d11949 100644 --- a/packages/openapi-react-query/test/index.test.tsx +++ b/packages/openapi-react-query/test/index.test.tsx @@ -89,6 +89,21 @@ describe("client", () => { client.queryOptions("get", "/string-arrayX"); // @ts-expect-error: Missing 'post_id' param. client.queryOptions("get", "/blogposts/{post_id}", {}); + // @ts-expect-error: Undeclared query param. + client.queryOptions("get", "/query-params", { params: { query: { string: "a", undeclared: 1 } } }); + // @ts-expect-error: Undeclared path param. + client.queryOptions("get", "/blogposts/{post_id}", { params: { path: { post_id: "1", undeclared: "2" } } }); + }); + + it("infers the response type from parseAs", async () => { + const fetchClient = createFetchClient({ baseUrl }); + const client = createClient(fetchClient); + + const blob = client.queryOptions("get", "/string-array", { parseAs: "blob" }); + expectTypeOf>>().toEqualTypeOf(); + + const json = client.queryOptions("get", "/string-array"); + expectTypeOf>>().toEqualTypeOf(); }); it("correctly infers return type from query key", async () => {