diff --git a/.changepacks/changepack_log_2_T-K8HzrduvcHMnQArqi.json b/.changepacks/changepack_log_2_T-K8HzrduvcHMnQArqi.json new file mode 100644 index 0000000..03a9497 --- /dev/null +++ b/.changepacks/changepack_log_2_T-K8HzrduvcHMnQArqi.json @@ -0,0 +1 @@ +{"changes":{"packages/react-query/package.json":"Patch"},"note":"Fix useQueries type","date":"2026-03-25T11:48:18.986238800Z"} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 8342c4a..1e47848 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,4 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json .claude .sisyphus +.omc diff --git a/bun.lock b/bun.lock index 8b76bd2..0053340 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,7 @@ "name": "devup-api", "devDependencies": { "@biomejs/biome": "^2.3", + "@devup-api/react-query": "^0.1.10", "@testing-library/react": "^16.3.2", "@testing-library/react-hooks": "^8.0.1", "@types/bun": "latest", @@ -95,7 +96,7 @@ }, "packages/core": { "name": "@devup-api/core", - "version": "0.1.13", + "version": "0.1.14", "devDependencies": { "@types/node": "^25.2", "typescript": "^5.9", @@ -103,7 +104,7 @@ }, "packages/fetch": { "name": "@devup-api/fetch", - "version": "0.1.16", + "version": "0.1.18", "dependencies": { "@devup-api/core": "workspace:^", }, @@ -114,7 +115,7 @@ }, "packages/generator": { "name": "@devup-api/generator", - "version": "0.1.18", + "version": "0.1.19", "dependencies": { "@devup-api/core": "workspace:^", "@devup-api/utils": "workspace:^", @@ -158,7 +159,7 @@ }, "packages/next-plugin": { "name": "@devup-api/next-plugin", - "version": "0.1.10", + "version": "0.1.11", "dependencies": { "@devup-api/core": "workspace:^", "@devup-api/generator": "workspace:^", @@ -177,7 +178,7 @@ }, "packages/react-query": { "name": "@devup-api/react-query", - "version": "0.1.9", + "version": "0.1.10", "dependencies": { "@devup-api/fetch": "workspace:^", "@tanstack/react-query": ">=5.90", @@ -196,7 +197,7 @@ }, "packages/rsbuild-plugin": { "name": "@devup-api/rsbuild-plugin", - "version": "0.1.10", + "version": "0.1.11", "dependencies": { "@devup-api/core": "workspace:^", "@devup-api/generator": "workspace:^", @@ -241,7 +242,7 @@ }, "packages/vite-plugin": { "name": "@devup-api/vite-plugin", - "version": "0.1.10", + "version": "0.1.11", "dependencies": { "@devup-api/core": "workspace:^", "@devup-api/generator": "workspace:^", @@ -259,7 +260,7 @@ }, "packages/webpack-plugin": { "name": "@devup-api/webpack-plugin", - "version": "0.1.10", + "version": "0.1.11", "dependencies": { "@devup-api/core": "workspace:^", "@devup-api/generator": "workspace:^", diff --git a/package.json b/package.json index 0eb41ca..ac12974 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "private": true, "devDependencies": { "@biomejs/biome": "^2.3", + "@devup-api/react-query": "workspace:*", "@testing-library/react": "^16.3.2", "@testing-library/react-hooks": "^8.0.1", "@types/bun": "latest", diff --git a/packages/react-query/src/__tests__/types.test.ts b/packages/react-query/src/__tests__/types.test.ts new file mode 100644 index 0000000..bf68750 --- /dev/null +++ b/packages/react-query/src/__tests__/types.test.ts @@ -0,0 +1,213 @@ +/** + * Type tests for DevupQueryClient + * Verify that useQueries type inference works correctly per element + */ +import { describe, expectTypeOf, test } from 'bun:test' +import type { DevupGetApiStruct } from '@devup-api/core' +import type { DevupQueryClient } from '../query-client' + +// ============================================================================= +// Test Fixtures +// ============================================================================= + +declare module '@devup-api/core' { + interface DevupApiServers { + 'react-query-test.json': never + } + + interface DevupGetApiStruct { + 'react-query-test.json': { + '/users': { + response: { id: number; name: string }[] + error: { message: string } + } + '/users/{id}': { + params: { id: string } + response: { id: number; name: string; email: string } + error: { message: string; code: number } + } + '/posts': { + response: { id: number; title: string }[] + error: { message: string } + } + } + } + + interface DevupPostApiStruct { + 'react-query-test.json': { + '/users': { + body: { name: string; email: string } + response: { id: number } + error: { message: string } + } + } + } + + interface DevupDeleteApiStruct { + 'react-query-test.json': { + '/users/{id}': { + params: { id: string } + response: { success: boolean } + error: { message: string } + } + } + } +} + +type QC = DevupQueryClient<'react-query-test.json'> + +// ============================================================================= +// useQueries - Per-element return type inference +// ============================================================================= + +describe('useQueries per-element type inference', () => { + test('different endpoints return different response types', () => { + type Result = ReturnType< + ( + qc: QC, + ) => ReturnType< + typeof qc.useQueries<[['get', '/users'], ['get', '/posts']]> + > + > + + expectTypeOf().toEqualTypeOf< + { id: number; name: string }[] | undefined + >() + expectTypeOf().toEqualTypeOf< + { id: number; title: string }[] | undefined + >() + }) + + test('different endpoints return different error types', () => { + type Result = ReturnType< + ( + qc: QC, + ) => ReturnType< + typeof qc.useQueries< + [ + ['get', '/users/{id}', { params: { id: string } }], + ['get', '/users'], + ] + > + > + > + + expectTypeOf().toEqualTypeOf< + { id: number; name: string; email: string } | undefined + >() + expectTypeOf().toEqualTypeOf<{ + message: string + code: number + } | null>() + expectTypeOf().toEqualTypeOf< + { id: number; name: string }[] | undefined + >() + expectTypeOf().toEqualTypeOf<{ + message: string + } | null>() + }) + + test('single element preserves exact type', () => { + type Result = ReturnType< + (qc: QC) => ReturnType> + > + + expectTypeOf().toEqualTypeOf< + { id: number; title: string }[] | undefined + >() + }) + + test('result types are not intersected', () => { + type Result = ReturnType< + ( + qc: QC, + ) => ReturnType< + typeof qc.useQueries<[['get', '/users'], ['get', '/posts']]> + > + > + + // result[0] should NOT have title (from /posts) + type Data0 = NonNullable[number] + expectTypeOf< + 'title' extends keyof Data0 ? true : false + >().toEqualTypeOf() + + // result[1] should NOT have name (from /users) + type Data1 = NonNullable[number] + expectTypeOf< + 'name' extends keyof Data1 ? true : false + >().toEqualTypeOf() + }) +}) + +// ============================================================================= +// useQueries - params constraint +// ============================================================================= + +describe('useQueries params constraint', () => { + test('endpoint with params accepts params option', () => { + // This should be valid: correct params provided + type _Valid = ReturnType< + ( + qc: QC, + ) => ReturnType< + typeof qc.useQueries< + [['get', '/users/{id}', { params: { id: string } }]] + > + > + > + }) + + test('endpoint without params has optional options', () => { + // This should be valid: no options needed + type _Valid = ReturnType< + (qc: QC) => ReturnType> + > + }) +}) + +// ============================================================================= +// useQuery - single query type inference (baseline) +// ============================================================================= + +describe('useQuery type inference baseline', () => { + test('response type inferred from endpoint', () => { + type Result = ReturnType< + ( + qc: QC, + ) => ReturnType< + typeof qc.useQuery< + 'get', + DevupGetApiStruct['react-query-test.json'], + '/users' + > + > + > + + expectTypeOf().toEqualTypeOf< + { id: number; name: string }[] | undefined + >() + }) + + test('error type inferred from endpoint', () => { + type Result = ReturnType< + ( + qc: QC, + ) => ReturnType< + typeof qc.useQuery< + 'get', + DevupGetApiStruct['react-query-test.json'], + '/users/{id}' + > + > + > + + expectTypeOf().toEqualTypeOf< + { id: number; name: string; email: string } | undefined + >() + expectTypeOf().toEqualTypeOf<{ + message: string + code: number + } | null>() + }) +}) diff --git a/packages/react-query/src/query-client.ts b/packages/react-query/src/query-client.ts index 3fed5b9..2eec699 100644 --- a/packages/react-query/src/query-client.ts +++ b/packages/react-query/src/query-client.ts @@ -19,6 +19,55 @@ import { useSuspenseQuery, } from '@tanstack/react-query' +type ResolveScope< + S extends ConditionalKeys, + M extends DevupApiMethodKeys, + P extends string, +> = Additional> + +type UseQueryOptions = Omit< + Parameters< + typeof useQuery, ExtractValue> + >[0], + 'queryFn' | 'queryKey' +> + +type UseQueriesTuple = [ + method: M, + path: P, + options?: ConditionalApiOption, + queryOptions?: UseQueryOptions, +] + +type UseQueriesEntry> = { + [M in DevupApiMethodKeys]: { + [P in ConditionalKeys>]: UseQueriesTuple< + ResolveScope, + P, + M + > + }[ConditionalKeys>] +}[DevupApiMethodKeys] + +type InferUseQueryResult< + S extends ConditionalKeys, + Q, +> = Q extends [infer M extends DevupApiMethodKeys, infer P, ...unknown[]] + ? P extends ConditionalKeys> + ? ReturnType< + typeof useQuery< + ExtractValue, 'response'>, + ExtractValue, 'error'> + > + > + : ReturnType + : ReturnType + +type UseQueriesResults< + S extends ConditionalKeys, + T extends readonly unknown[], +> = { -readonly [K in keyof T]: InferUseQueryResult } + export function getQueryKey( method: M, path: P, @@ -227,29 +276,12 @@ export class DevupQueryClient> { } useQueries< - M extends DevupApiMethodKeys, - ST extends DevupApiMethodScope, - T extends ConditionalKeys, - O extends Additional, - D extends ExtractValue, - E extends ExtractValue, - TCombinedResult = Array>>, + T extends UseQueriesEntry[], + TCombinedResult = UseQueriesResults, >( - queries: Array< - [ - method: M, - path: T, - options?: ConditionalApiOption, - queryOptions?: Omit< - Parameters>[0], - 'queryFn' | 'queryKey' - >, - ] - >, + queries: [...T], options?: { - combine?: ( - results: Array>>, - ) => TCombinedResult + combine?: (results: UseQueriesResults) => TCombinedResult queryClient?: Parameters[1] }, ): TCombinedResult { @@ -261,19 +293,25 @@ export class DevupQueryClient> { queryKey: [methodKey, pathKey, ...restOptions], signal, }: { - queryKey: [M, T, ...unknown[]] + queryKey: [string, string, ...unknown[]] signal: AbortSignal - }): Promise => + }): Promise => // biome-ignore lint/suspicious/noExplicitAny: can't use method as a function (this.api as any) [methodKey as string](pathKey, { signal, ...(restOptions[0] as DevupApiRequestInit), }) - .then(({ data, error, isError }: DevupApiResponse) => { - if (isError) throw error - return data - }), + .then( + ({ + data, + error, + isError, + }: DevupApiResponse) => { + if (isError) throw error + return data + }, + ), ...queryOptions, })) as Parameters[0]['queries'], combine: options?.combine as Parameters<