Skip to content

Commit 85acc4c

Browse files
kevin-dphugiexautofix-ci[bot]claude
authored
fix(query-db-collection): align queryOptions interop types (simplified) (#1289)
* fix(query-db-collection): align queryOptions interop types * ci: apply automated fixes * chore(changeset): add query options interop patch note * fix(query-db-collection): require queryFn in interop typing * refactor(query-db-collection): remove interop overloads, keep type widenings The broader type widenings (queryFn return T | Promise<T>, enabled as QueryObserverOptions['enabled'], TQueryData generics) are sufficient to handle queryOptions(...) interop without dedicated interop overloads. This removes TaggedQueryKey, QueryOptionsInteropConfig, and the two interop-specific overloads, reducing type complexity while preserving all 24 type tests passing. Existing tests are unchanged from main — only new interop tests are added. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Updated changeset * Updated docs --------- Co-authored-by: hugiex <hieunguynex@gmail.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 77b815e commit 85acc4c

File tree

4 files changed

+230
-19
lines changed

4 files changed

+230
-19
lines changed
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@tanstack/query-db-collection': patch
3+
---
4+
5+
Improve `queryCollectionOptions` type compatibility with TanStack Query option objects.
6+
7+
- Accept `queryFn` return types of `T | Promise<T>` instead of requiring `Promise<T>`.
8+
- Align `enabled`, `staleTime`, `refetchInterval`, `retry`, and `retryDelay` with `QueryObserverOptions` typing.
9+
- Support tagged `queryKey` values (`DataTag`) from `queryOptions(...)` spread usage.
10+
- Preserve runtime safety: query collections still require an executable `queryFn`, and wrapped responses still require `select`.

docs/collections/query-collection.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,38 @@ The `queryCollectionOptions` function accepts the following options:
6464
- `staleTime`: How long data is considered fresh
6565
- `meta`: Optional metadata that will be passed to the query function context
6666

67+
### Using with `queryOptions(...)`
68+
69+
If your app already uses TanStack Query's `queryOptions` helper (e.g. from `@tanstack/react-query`), you can spread those options into `queryCollectionOptions`. Note that `queryFn` must be explicitly provided since query collections require it both in types and at runtime:
70+
71+
```typescript
72+
import { QueryClient } from "@tanstack/query-core"
73+
import { createCollection } from "@tanstack/db"
74+
import { queryCollectionOptions } from "@tanstack/query-db-collection"
75+
import { queryOptions } from "@tanstack/react-query"
76+
77+
const queryClient = new QueryClient()
78+
79+
const listOptions = queryOptions({
80+
queryKey: ["todos"],
81+
queryFn: async () => {
82+
const response = await fetch("/api/todos")
83+
return response.json() as Promise<Array<{ id: string; title: string }>>
84+
},
85+
})
86+
87+
const todosCollection = createCollection(
88+
queryCollectionOptions({
89+
...listOptions,
90+
queryFn: (context) => listOptions.queryFn!(context),
91+
queryClient,
92+
getKey: (item) => item.id,
93+
}),
94+
)
95+
```
96+
97+
If `queryFn` is missing at runtime, `queryCollectionOptions` throws `QueryFnRequiredError`.
98+
6799
### Collection Options
68100

69101
- `id`: Unique identifier for the collection

packages/query-db-collection/src/query.ts

Lines changed: 29 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,9 @@ type TQueryKeyBuilder<TQueryKey> = (opts: LoadSubsetOptions) => TQueryKey
5959
*/
6060
export interface QueryCollectionConfig<
6161
T extends object = object,
62-
TQueryFn extends (context: QueryFunctionContext<any>) => Promise<any> = (
62+
TQueryFn extends (context: QueryFunctionContext<any>) => any = (
6363
context: QueryFunctionContext<any>,
64-
) => Promise<any>,
64+
) => any,
6565
TError = unknown,
6666
TQueryKey extends QueryKey = QueryKey,
6767
TKey extends string | number = string | number,
@@ -73,8 +73,8 @@ export interface QueryCollectionConfig<
7373
/** Function that fetches data from the server. Must return the complete collection state */
7474
queryFn: TQueryFn extends (
7575
context: QueryFunctionContext<TQueryKey>,
76-
) => Promise<Array<any>>
77-
? (context: QueryFunctionContext<TQueryKey>) => Promise<Array<T>>
76+
) => Promise<Array<any>> | Array<any>
77+
? (context: QueryFunctionContext<TQueryKey>) => Promise<Array<T>> | Array<T>
7878
: TQueryFn
7979
/* Function that extracts array items from wrapped API responses (e.g metadata, pagination) */
8080
select?: (data: TQueryData) => Array<T>
@@ -83,33 +83,39 @@ export interface QueryCollectionConfig<
8383

8484
// Query-specific options
8585
/** Whether the query should automatically run (default: true) */
86-
enabled?: boolean
87-
refetchInterval?: QueryObserverOptions<
88-
Array<T>,
86+
enabled?: QueryObserverOptions<
87+
TQueryData,
8988
TError,
9089
Array<T>,
90+
TQueryData,
91+
TQueryKey
92+
>[`enabled`]
93+
refetchInterval?: QueryObserverOptions<
94+
TQueryData,
95+
TError,
9196
Array<T>,
97+
TQueryData,
9298
TQueryKey
9399
>[`refetchInterval`]
94100
retry?: QueryObserverOptions<
95-
Array<T>,
101+
TQueryData,
96102
TError,
97103
Array<T>,
98-
Array<T>,
104+
TQueryData,
99105
TQueryKey
100106
>[`retry`]
101107
retryDelay?: QueryObserverOptions<
102-
Array<T>,
108+
TQueryData,
103109
TError,
104110
Array<T>,
105-
Array<T>,
111+
TQueryData,
106112
TQueryKey
107113
>[`retryDelay`]
108114
staleTime?: QueryObserverOptions<
109-
Array<T>,
115+
TQueryData,
110116
TError,
111117
Array<T>,
112-
Array<T>,
118+
TQueryData,
113119
TQueryKey
114120
>[`staleTime`]
115121

@@ -393,7 +399,7 @@ class QueryCollectionUtilsImpl {
393399
// Overload for when schema is provided and select present
394400
export function queryCollectionOptions<
395401
T extends StandardSchemaV1,
396-
TQueryFn extends (context: QueryFunctionContext<any>) => Promise<any>,
402+
TQueryFn extends (context: QueryFunctionContext<any>) => any,
397403
TError = unknown,
398404
TQueryKey extends QueryKey = QueryKey,
399405
TKey extends string | number = string | number,
@@ -428,9 +434,9 @@ export function queryCollectionOptions<
428434
// Overload for when no schema is provided and select present
429435
export function queryCollectionOptions<
430436
T extends object,
431-
TQueryFn extends (context: QueryFunctionContext<any>) => Promise<any> = (
437+
TQueryFn extends (context: QueryFunctionContext<any>) => any = (
432438
context: QueryFunctionContext<any>,
433-
) => Promise<any>,
439+
) => any,
434440
TError = unknown,
435441
TQueryKey extends QueryKey = QueryKey,
436442
TKey extends string | number = string | number,
@@ -469,7 +475,7 @@ export function queryCollectionOptions<
469475
InferSchemaOutput<T>,
470476
(
471477
context: QueryFunctionContext<any>,
472-
) => Promise<Array<InferSchemaOutput<T>>>,
478+
) => Array<InferSchemaOutput<T>> | Promise<Array<InferSchemaOutput<T>>>,
473479
TError,
474480
TQueryKey,
475481
TKey,
@@ -501,7 +507,7 @@ export function queryCollectionOptions<
501507
>(
502508
config: QueryCollectionConfig<
503509
T,
504-
(context: QueryFunctionContext<any>) => Promise<Array<T>>,
510+
(context: QueryFunctionContext<any>) => Array<T> | Promise<Array<T>>,
505511
TError,
506512
TQueryKey,
507513
TKey
@@ -519,7 +525,10 @@ export function queryCollectionOptions<
519525
}
520526

521527
export function queryCollectionOptions(
522-
config: QueryCollectionConfig<Record<string, unknown>>,
528+
config: QueryCollectionConfig<
529+
Record<string, unknown>,
530+
(context: QueryFunctionContext<any>) => any
531+
>,
523532
): CollectionConfig<
524533
Record<string, unknown>,
525534
string | number,
@@ -555,6 +564,7 @@ export function queryCollectionOptions(
555564
if (!queryKey) {
556565
throw new QueryKeyRequiredError()
557566
}
567+
558568
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
559569
if (!queryFn) {
560570
throw new QueryFnRequiredError()

packages/query-db-collection/tests/query.test-d.ts

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ import {
1010
import { QueryClient } from '@tanstack/query-core'
1111
import { z } from 'zod'
1212
import { queryCollectionOptions } from '../src/query'
13+
import type {
14+
DataTag,
15+
QueryFunctionContext,
16+
QueryObserverOptions,
17+
} from '@tanstack/query-core'
1318
import type { QueryCollectionConfig, QueryCollectionUtils } from '../src/query'
1419
import type {
1520
DeleteMutationFnParams,
@@ -561,6 +566,160 @@ describe(`Query collection type resolution tests`, () => {
561566
})
562567
})
563568

569+
describe(`queryOptions interoperability`, () => {
570+
type NumberItem = {
571+
id: number
572+
value: string
573+
}
574+
type TaggedNumbersKey = DataTag<Array<string>, Array<NumberItem>, Error>
575+
type NumberQueryObserverOptions = QueryObserverOptions<
576+
Array<NumberItem>,
577+
Error,
578+
Array<NumberItem>,
579+
Array<NumberItem>,
580+
TaggedNumbersKey
581+
>
582+
const taggedNumbersQueryKey = [
583+
`query-options-numbers`,
584+
] as unknown as TaggedNumbersKey
585+
586+
it(`should accept queryOptions-like spread config with tagged queryKey`, () => {
587+
const queryOptionsLike = {
588+
queryKey: taggedNumbersQueryKey,
589+
queryFn: () =>
590+
Promise.resolve([
591+
{ id: 1, value: `one` },
592+
{ id: 2, value: `two` },
593+
]),
594+
} satisfies {
595+
queryKey: TaggedNumbersKey
596+
queryFn?: NumberQueryObserverOptions[`queryFn`]
597+
}
598+
599+
const options = queryCollectionOptions({
600+
...queryOptionsLike,
601+
queryClient,
602+
getKey: (item) => item.id,
603+
})
604+
605+
expectTypeOf(options.getKey).parameters.toEqualTypeOf<[NumberItem]>()
606+
})
607+
608+
it(`should accept enabled from queryOptions-like config`, () => {
609+
const queryOptionsLike = {
610+
queryKey: taggedNumbersQueryKey,
611+
queryFn: () => Promise.resolve([{ id: 1, value: `one` }]),
612+
enabled: (_query) => true,
613+
} satisfies {
614+
queryKey: TaggedNumbersKey
615+
queryFn?: NumberQueryObserverOptions[`queryFn`]
616+
enabled?: NumberQueryObserverOptions[`enabled`]
617+
}
618+
619+
const options = queryCollectionOptions({
620+
...queryOptionsLike,
621+
queryClient,
622+
getKey: (item) => item.id,
623+
})
624+
625+
expectTypeOf(options.getKey).parameters.toEqualTypeOf<[NumberItem]>()
626+
})
627+
628+
it(`should require explicit queryFn when source type marks queryFn optional`, () => {
629+
const queryOptionsLike: {
630+
queryKey: TaggedNumbersKey
631+
queryFn?: (
632+
context: QueryFunctionContext<TaggedNumbersKey>,
633+
) => Array<NumberItem> | Promise<Array<NumberItem>>
634+
} = {
635+
queryKey: taggedNumbersQueryKey,
636+
queryFn: () => Promise.resolve([{ id: 1, value: `one` }]),
637+
}
638+
639+
// @ts-expect-error - interop configs require queryFn even when source type marks it optional
640+
queryCollectionOptions({
641+
...queryOptionsLike,
642+
queryClient,
643+
getKey: (item) => item.id,
644+
})
645+
646+
const options = queryCollectionOptions({
647+
...queryOptionsLike,
648+
queryFn: (context) => queryOptionsLike.queryFn!(context),
649+
queryClient,
650+
getKey: (item) => item.id,
651+
})
652+
653+
expectTypeOf(options.getKey).parameters.toEqualTypeOf<[NumberItem]>()
654+
})
655+
656+
it(`should require select for wrapped queryOptions-like responses`, () => {
657+
type WrappedResponse = {
658+
total: number
659+
items: Array<NumberItem>
660+
}
661+
type TaggedWrappedKey = DataTag<Array<string>, WrappedResponse, Error>
662+
type WrappedObserverOptions = QueryObserverOptions<
663+
WrappedResponse,
664+
Error,
665+
WrappedResponse,
666+
WrappedResponse,
667+
TaggedWrappedKey
668+
>
669+
const taggedWrappedQueryKey = [
670+
`query-options-wrapped`,
671+
] as unknown as TaggedWrappedKey
672+
673+
const wrappedQueryOptionsLike = {
674+
queryKey: taggedWrappedQueryKey,
675+
queryFn: () =>
676+
Promise.resolve({
677+
total: 1,
678+
items: [{ id: 1, value: `one` }],
679+
}),
680+
} satisfies {
681+
queryKey: TaggedWrappedKey
682+
queryFn?: WrappedObserverOptions[`queryFn`]
683+
}
684+
685+
// @ts-expect-error - wrapped response requires select to extract the item array
686+
queryCollectionOptions({
687+
...wrappedQueryOptionsLike,
688+
queryClient,
689+
getKey: () => 1,
690+
})
691+
692+
const options = queryCollectionOptions({
693+
...wrappedQueryOptionsLike,
694+
select: (response) => response.items,
695+
queryClient,
696+
getKey: (item) => item.id,
697+
})
698+
699+
expectTypeOf(options.getKey).parameters.toEqualTypeOf<[NumberItem]>()
700+
})
701+
702+
it(`should still require queryFn for plain configs`, () => {
703+
// @ts-expect-error - queryFn is required for plain configs
704+
queryCollectionOptions<NumberItem>({
705+
queryClient,
706+
queryKey: [`query-options-missing-query-fn`],
707+
getKey: (item) => item.id,
708+
})
709+
})
710+
711+
it(`should accept synchronous queryFn return values`, () => {
712+
const options = queryCollectionOptions<NumberItem>({
713+
queryClient,
714+
queryKey: [`query-options-sync-query-fn`],
715+
queryFn: () => [{ id: 1, value: `one` }],
716+
getKey: (item) => item.id,
717+
})
718+
719+
expectTypeOf(options.getKey).parameters.toEqualTypeOf<[NumberItem]>()
720+
})
721+
})
722+
564723
it(`should type collection.utils as QueryCollectionUtils after createCollection`, () => {
565724
const collection = createCollection(
566725
queryCollectionOptions<ExplicitType>({

0 commit comments

Comments
 (0)