diff --git a/docs/framework/react/guides/parallel-queries.md b/docs/framework/react/guides/parallel-queries.md index 5d078b134ea..83e70f46f2b 100644 --- a/docs/framework/react/guides/parallel-queries.md +++ b/docs/framework/react/guides/parallel-queries.md @@ -56,3 +56,8 @@ function App({ users }) { ``` [//]: # 'Example2' +[//]: # 'TypeScriptSelect' + +> When using TypeScript, an inline `select` written on a query object passed to `useQueries` can't infer its `data` argument from that same object's `queryFn` — it falls back to `unknown`. Annotate the `select` parameter explicitly, or define the query with the [`queryOptions`](../reference/queryOptions.md) helper, to keep type inference. See [this known limitation](https://github.com/TanStack/query/issues/6556). + +[//]: # 'TypeScriptSelect' diff --git a/docs/framework/react/reference/useQueries.md b/docs/framework/react/reference/useQueries.md index d41a73a2b1c..1048b532d29 100644 --- a/docs/framework/react/reference/useQueries.md +++ b/docs/framework/react/reference/useQueries.md @@ -65,3 +65,81 @@ The `combine` function will only re-run if: - any of the query results changed This means that an inlined `combine` function, as shown above, will run on every render. To avoid this, you can wrap the `combine` function in `useCallback`, or extract it to a stable function reference if it doesn't have any dependencies. + +## TypeScript: typing the `select` option + +Unlike `useQuery`, `useQueries` cannot infer the `data` argument of an _inline_ `select` from its sibling `queryFn`. Because `useQueries` infers the type of the whole `queries` array at once, the `select` parameter of a query object written inline cannot be contextually typed from that same object's `queryFn`, so it falls back to `unknown`. This is a [known TypeScript limitation](https://github.com/TanStack/query/issues/6556). + +```tsx +useQueries({ + queries: [ + { + queryKey: ['post', 1], + queryFn: () => fetchPost(1), + // ❌ `data` is `unknown` here + select: (data) => data.title, + }, + ], +}) +``` + +There are two supported workarounds: + +1. Annotate the `select` parameter explicitly: + +```tsx +useQueries({ + queries: [ + { + queryKey: ['post', 1], + queryFn: () => fetchPost(1), + // ✅ `data` is `Post` + select: (data: Post) => data.title, + }, + ], +}) +``` + +2. Define the query with the [`queryOptions`](./queryOptions.md) helper, which resolves its types in a single object _before_ it reaches `useQueries`: + +```tsx +const postOptions = (id: number) => + queryOptions({ + queryKey: ['post', id], + queryFn: () => fetchPost(id), + // ✅ `data` is `Post` + select: (data) => data.title, + }) + +useQueries({ queries: [postOptions(1), postOptions(2)] }) +``` + +The same limitation applies when you spread a `queryOptions` result to override its `select` inline — the overriding `select` still falls back to `unknown`: + +```tsx +useQueries({ + queries: [ + { + ...postOptions(1), + // ❌ `data` is `unknown` here + select: (data) => data.title, + }, + ], +}) +``` + +Wrap the spread in `queryOptions` again so the override is resolved before it reaches `useQueries`: + +```tsx +useQueries({ + queries: [ + queryOptions({ + ...postOptions(1), + // ✅ `data` is `Post` + select: (data) => data.title, + }), + ], +}) +``` + +The same applies to [`useSuspenseQueries`](./useSuspenseQueries.md). diff --git a/docs/framework/react/reference/useSuspenseQueries.md b/docs/framework/react/reference/useSuspenseQueries.md index 702d791ee48..5d106da3e9a 100644 --- a/docs/framework/react/reference/useSuspenseQueries.md +++ b/docs/framework/react/reference/useSuspenseQueries.md @@ -16,6 +16,8 @@ The same as for [useQueries](./useQueries.md), except that each `query` can't ha - `enabled` - `placeholderData` +> The [`select` typing caveat](./useQueries.md#typescript-typing-the-select-option) for `useQueries` applies here as well: annotate the `select` parameter or use the [`queryOptions`](./queryOptions.md) helper to keep type inference. + **Returns** Same structure as [useQueries](./useQueries.md), except that for each `query`: diff --git a/packages/react-query/src/__tests__/useQueries.test-d.tsx b/packages/react-query/src/__tests__/useQueries.test-d.tsx index 32f1d566269..e4bd6497668 100644 --- a/packages/react-query/src/__tests__/useQueries.test-d.tsx +++ b/packages/react-query/src/__tests__/useQueries.test-d.tsx @@ -868,4 +868,173 @@ describe('useQueries', () => { } }) }) + + describe('select', () => { + // Inferring the `select` argument of an *inline* query object from its + // sibling `queryFn` is a known TypeScript limitation, because `useQueries` + // infers its array generic from the argument itself. The two supported + // workarounds are to annotate the `select` parameter, or to define the + // query with the `queryOptions` helper. + // https://github.com/TanStack/query/issues/6556 + + describe('without queryOptions (inline query object)', () => { + it('leaves the select argument as `unknown` without an annotation', () => { + useQueries({ + queries: [ + { + queryKey: queryKey(), + queryFn: () => Promise.resolve(1), + select: (data) => { + expectTypeOf(data).toBeUnknown() + // @ts-expect-error `data` is `unknown`, not the expected `number` + return data.toFixed() + }, + }, + ], + }) + }) + + it('infers the result when the select parameter is annotated', () => { + const queryResults = useQueries({ + queries: [ + { + queryKey: queryKey(), + queryFn: () => Promise.resolve(1), + select: (data: number) => data.toFixed(), + }, + ], + }) + expectTypeOf(queryResults[0].data).toEqualTypeOf() + }) + }) + + describe('with queryOptions passed directly', () => { + it('without select, infers the queryFn data as the result', () => { + const options = queryOptions({ + queryKey: queryKey(), + queryFn: () => Promise.resolve(1), + }) + const queryResults = useQueries({ queries: [options] }) + expectTypeOf(queryResults[0].data).toEqualTypeOf() + }) + + it('with select, infers the select argument and the result', () => { + const options = queryOptions({ + queryKey: queryKey(), + queryFn: () => Promise.resolve(1), + select: (data) => { + expectTypeOf(data).toEqualTypeOf() + return data.toFixed() + }, + }) + const queryResults = useQueries({ queries: [options] }) + expectTypeOf(queryResults[0].data).toEqualTypeOf() + }) + + it('infers select when a base queryOptions is re-wrapped with queryOptions', () => { + const baseOptions = queryOptions({ + queryKey: queryKey(), + queryFn: () => Promise.resolve(1), + }) + const queryResults = useQueries({ + queries: [ + queryOptions({ + ...baseOptions, + select: (data) => { + expectTypeOf(data).toEqualTypeOf() + return data.toFixed() + }, + }), + baseOptions, + ], + }) + expectTypeOf(queryResults[0].data).toEqualTypeOf() + expectTypeOf(queryResults[1].data).toEqualTypeOf() + }) + + it('infers an overriding select when a queryOptions with a select is re-wrapped with queryOptions', () => { + const baseOptions = queryOptions({ + queryKey: queryKey(), + queryFn: () => Promise.resolve(1), + select: (data) => data + 1, + }) + const queryResults = useQueries({ + queries: [ + queryOptions({ + ...baseOptions, + select: (data) => { + expectTypeOf(data).toEqualTypeOf() + return data.toFixed() + }, + }), + ], + }) + expectTypeOf(queryResults[0].data).toEqualTypeOf() + }) + }) + + describe('with queryOptions spread into an inline query object', () => { + it('without select in the factory, leaves an unannotated select as `unknown`', () => { + const options = queryOptions({ + queryKey: queryKey(), + queryFn: () => Promise.resolve(1), + }) + useQueries({ + queries: [ + // @ts-expect-error Without an annotation the inline `select` receives `data: unknown`, which makes the whole spread query object unassignable to the expected options type + { + ...options, + select: (data) => { + expectTypeOf(data).toBeUnknown() + return data + }, + }, + ], + }) + }) + + it('without select in the factory, an annotated select compiles', () => { + const options = queryOptions({ + queryKey: queryKey(), + queryFn: () => Promise.resolve(1), + }) + const queryResults = useQueries({ + queries: [{ ...options, select: (data: number) => data.toFixed() }], + }) + expectTypeOf(queryResults[0].data).toEqualTypeOf() + }) + + it('with select in the factory, leaves an unannotated overriding select as `unknown`', () => { + const options = queryOptions({ + queryKey: queryKey(), + queryFn: () => Promise.resolve(1), + select: (data) => data + 1, + }) + useQueries({ + queries: [ + // @ts-expect-error Without an annotation the inline `select` receives `data: unknown`, which makes the whole spread query object unassignable to the expected options type + { + ...options, + select: (data) => { + expectTypeOf(data).toBeUnknown() + return data + }, + }, + ], + }) + }) + + it('with select in the factory, an annotated overriding select compiles', () => { + const options = queryOptions({ + queryKey: queryKey(), + queryFn: () => Promise.resolve(1), + select: (data) => data + 1, + }) + const queryResults = useQueries({ + queries: [{ ...options, select: (data: number) => data.toFixed() }], + }) + expectTypeOf(queryResults[0].data).toEqualTypeOf() + }) + }) + }) }) diff --git a/packages/react-query/src/__tests__/useSuspenseQueries.test-d.tsx b/packages/react-query/src/__tests__/useSuspenseQueries.test-d.tsx index 98bf336c8df..d9230bdf940 100644 --- a/packages/react-query/src/__tests__/useSuspenseQueries.test-d.tsx +++ b/packages/react-query/src/__tests__/useSuspenseQueries.test-d.tsx @@ -254,4 +254,173 @@ describe('UseSuspenseQueries config object overload', () => { }), ) }) + + describe('select', () => { + // Inferring the `select` argument of an *inline* query object from its + // sibling `queryFn` is a known TypeScript limitation, because + // `useSuspenseQueries` infers its array generic from the argument itself. + // The two supported workarounds are to annotate the `select` parameter, or + // to define the query with the `queryOptions` helper. + // https://github.com/TanStack/query/issues/6556 + + describe('without queryOptions (inline query object)', () => { + it('leaves the select argument as `unknown` without an annotation', () => { + useSuspenseQueries({ + queries: [ + { + queryKey: queryKey(), + queryFn: () => Promise.resolve(1), + select: (data) => { + expectTypeOf(data).toBeUnknown() + // @ts-expect-error `data` is `unknown`, not the expected `number` + return data.toFixed() + }, + }, + ], + }) + }) + + it('infers the result when the select parameter is annotated', () => { + const queryResults = useSuspenseQueries({ + queries: [ + { + queryKey: queryKey(), + queryFn: () => Promise.resolve(1), + select: (data: number) => data.toFixed(), + }, + ], + }) + expectTypeOf(queryResults[0].data).toEqualTypeOf() + }) + }) + + describe('with queryOptions passed directly', () => { + it('without select, infers the queryFn data as the result', () => { + const options = queryOptions({ + queryKey: queryKey(), + queryFn: () => Promise.resolve(1), + }) + const queryResults = useSuspenseQueries({ queries: [options] }) + expectTypeOf(queryResults[0].data).toEqualTypeOf() + }) + + it('with select, infers the select argument and the result', () => { + const options = queryOptions({ + queryKey: queryKey(), + queryFn: () => Promise.resolve(1), + select: (data) => { + expectTypeOf(data).toEqualTypeOf() + return data.toFixed() + }, + }) + const queryResults = useSuspenseQueries({ queries: [options] }) + expectTypeOf(queryResults[0].data).toEqualTypeOf() + }) + + it('infers select when a base queryOptions is re-wrapped with queryOptions', () => { + const baseOptions = queryOptions({ + queryKey: queryKey(), + queryFn: () => Promise.resolve(1), + }) + const queryResults = useSuspenseQueries({ + queries: [ + queryOptions({ + ...baseOptions, + select: (data) => { + expectTypeOf(data).toEqualTypeOf() + return data.toFixed() + }, + }), + baseOptions, + ], + }) + expectTypeOf(queryResults[0].data).toEqualTypeOf() + expectTypeOf(queryResults[1].data).toEqualTypeOf() + }) + + it('infers an overriding select when a queryOptions with a select is re-wrapped with queryOptions', () => { + const baseOptions = queryOptions({ + queryKey: queryKey(), + queryFn: () => Promise.resolve(1), + select: (data) => data + 1, + }) + const queryResults = useSuspenseQueries({ + queries: [ + queryOptions({ + ...baseOptions, + select: (data) => { + expectTypeOf(data).toEqualTypeOf() + return data.toFixed() + }, + }), + ], + }) + expectTypeOf(queryResults[0].data).toEqualTypeOf() + }) + }) + + describe('with queryOptions spread into an inline query object', () => { + it('without select in the factory, leaves an unannotated select untyped', () => { + const options = queryOptions({ + queryKey: queryKey(), + queryFn: () => Promise.resolve(1), + }) + useSuspenseQueries({ + queries: [ + { + ...options, + // @ts-expect-error Without an annotation the inline `select` parameter `data` implicitly has type `any` + select: (data) => { + expectTypeOf(data).toBeAny() + return data + }, + }, + ], + }) + }) + + it('without select in the factory, an annotated select compiles', () => { + const options = queryOptions({ + queryKey: queryKey(), + queryFn: () => Promise.resolve(1), + }) + const queryResults = useSuspenseQueries({ + queries: [{ ...options, select: (data: number) => data.toFixed() }], + }) + expectTypeOf(queryResults[0].data).toEqualTypeOf() + }) + + it('with select in the factory, leaves an unannotated overriding select untyped', () => { + const options = queryOptions({ + queryKey: queryKey(), + queryFn: () => Promise.resolve(1), + select: (data) => data + 1, + }) + useSuspenseQueries({ + queries: [ + { + ...options, + // @ts-expect-error Without an annotation the inline `select` parameter `data` implicitly has type `any` + select: (data) => { + expectTypeOf(data).toBeAny() + return data + }, + }, + ], + }) + }) + + it('with select in the factory, an annotated overriding select compiles', () => { + const options = queryOptions({ + queryKey: queryKey(), + queryFn: () => Promise.resolve(1), + select: (data) => data + 1, + }) + const queryResults = useSuspenseQueries({ + queries: [{ ...options, select: (data: number) => data.toFixed() }], + }) + expectTypeOf(queryResults[0].data).toEqualTypeOf() + }) + }) + }) })