Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/query-options-interop-types.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@tanstack/query-db-collection': patch
---

Improve `queryCollectionOptions` type compatibility with TanStack Query option objects.

- Accept `queryFn` return types of `T | Promise<T>` instead of requiring `Promise<T>`.
- Align `enabled`, `staleTime`, `refetchInterval`, `retry`, and `retryDelay` with `QueryObserverOptions` typing.
- Support tagged `queryKey` values (`DataTag`) from `queryOptions(...)` spread usage.
- Preserve runtime safety: query collections still require an executable `queryFn`, and wrapped responses still require `select`.
32 changes: 32 additions & 0 deletions docs/collections/query-collection.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,38 @@ The `queryCollectionOptions` function accepts the following options:
- `staleTime`: How long data is considered fresh
- `meta`: Optional metadata that will be passed to the query function context

### Using with `queryOptions(...)`

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:

```typescript
import { QueryClient } from "@tanstack/query-core"
import { createCollection } from "@tanstack/db"
import { queryCollectionOptions } from "@tanstack/query-db-collection"
import { queryOptions } from "@tanstack/react-query"

const queryClient = new QueryClient()

const listOptions = queryOptions({
queryKey: ["todos"],
queryFn: async () => {
const response = await fetch("/api/todos")
return response.json() as Promise<Array<{ id: string; title: string }>>
},
})

const todosCollection = createCollection(
queryCollectionOptions({
...listOptions,
queryFn: (context) => listOptions.queryFn!(context),
queryClient,
getKey: (item) => item.id,
}),
)
```

If `queryFn` is missing at runtime, `queryCollectionOptions` throws `QueryFnRequiredError`.

### Collection Options

- `id`: Unique identifier for the collection
Expand Down
48 changes: 29 additions & 19 deletions packages/query-db-collection/src/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,9 @@ type TQueryKeyBuilder<TQueryKey> = (opts: LoadSubsetOptions) => TQueryKey
*/
export interface QueryCollectionConfig<
T extends object = object,
TQueryFn extends (context: QueryFunctionContext<any>) => Promise<any> = (
TQueryFn extends (context: QueryFunctionContext<any>) => any = (
context: QueryFunctionContext<any>,
) => Promise<any>,
) => any,
TError = unknown,
TQueryKey extends QueryKey = QueryKey,
TKey extends string | number = string | number,
Expand All @@ -73,8 +73,8 @@ export interface QueryCollectionConfig<
/** Function that fetches data from the server. Must return the complete collection state */
queryFn: TQueryFn extends (
context: QueryFunctionContext<TQueryKey>,
) => Promise<Array<any>>
? (context: QueryFunctionContext<TQueryKey>) => Promise<Array<T>>
) => Promise<Array<any>> | Array<any>
? (context: QueryFunctionContext<TQueryKey>) => Promise<Array<T>> | Array<T>
: TQueryFn
/* Function that extracts array items from wrapped API responses (e.g metadata, pagination) */
select?: (data: TQueryData) => Array<T>
Expand All @@ -83,33 +83,39 @@ export interface QueryCollectionConfig<

// Query-specific options
/** Whether the query should automatically run (default: true) */
enabled?: boolean
refetchInterval?: QueryObserverOptions<
Array<T>,
enabled?: QueryObserverOptions<
TQueryData,
TError,
Array<T>,
TQueryData,
TQueryKey
>[`enabled`]
refetchInterval?: QueryObserverOptions<
TQueryData,
TError,
Array<T>,
TQueryData,
TQueryKey
>[`refetchInterval`]
retry?: QueryObserverOptions<
Array<T>,
TQueryData,
TError,
Array<T>,
Array<T>,
TQueryData,
TQueryKey
>[`retry`]
retryDelay?: QueryObserverOptions<
Array<T>,
TQueryData,
TError,
Array<T>,
Array<T>,
TQueryData,
TQueryKey
>[`retryDelay`]
staleTime?: QueryObserverOptions<
Array<T>,
TQueryData,
TError,
Array<T>,
Array<T>,
TQueryData,
TQueryKey
>[`staleTime`]

Expand Down Expand Up @@ -393,7 +399,7 @@ class QueryCollectionUtilsImpl {
// Overload for when schema is provided and select present
export function queryCollectionOptions<
T extends StandardSchemaV1,
TQueryFn extends (context: QueryFunctionContext<any>) => Promise<any>,
TQueryFn extends (context: QueryFunctionContext<any>) => any,
TError = unknown,
TQueryKey extends QueryKey = QueryKey,
TKey extends string | number = string | number,
Expand Down Expand Up @@ -428,9 +434,9 @@ export function queryCollectionOptions<
// Overload for when no schema is provided and select present
export function queryCollectionOptions<
T extends object,
TQueryFn extends (context: QueryFunctionContext<any>) => Promise<any> = (
TQueryFn extends (context: QueryFunctionContext<any>) => any = (
context: QueryFunctionContext<any>,
) => Promise<any>,
) => any,
TError = unknown,
TQueryKey extends QueryKey = QueryKey,
TKey extends string | number = string | number,
Expand Down Expand Up @@ -469,7 +475,7 @@ export function queryCollectionOptions<
InferSchemaOutput<T>,
(
context: QueryFunctionContext<any>,
) => Promise<Array<InferSchemaOutput<T>>>,
) => Array<InferSchemaOutput<T>> | Promise<Array<InferSchemaOutput<T>>>,
TError,
TQueryKey,
TKey,
Expand Down Expand Up @@ -501,7 +507,7 @@ export function queryCollectionOptions<
>(
config: QueryCollectionConfig<
T,
(context: QueryFunctionContext<any>) => Promise<Array<T>>,
(context: QueryFunctionContext<any>) => Array<T> | Promise<Array<T>>,
TError,
TQueryKey,
TKey
Expand All @@ -519,7 +525,10 @@ export function queryCollectionOptions<
}

export function queryCollectionOptions(
config: QueryCollectionConfig<Record<string, unknown>>,
config: QueryCollectionConfig<
Record<string, unknown>,
(context: QueryFunctionContext<any>) => any
>,
): CollectionConfig<
Record<string, unknown>,
string | number,
Expand Down Expand Up @@ -555,6 +564,7 @@ export function queryCollectionOptions(
if (!queryKey) {
throw new QueryKeyRequiredError()
}

// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!queryFn) {
throw new QueryFnRequiredError()
Expand Down
159 changes: 159 additions & 0 deletions packages/query-db-collection/tests/query.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ import {
import { QueryClient } from '@tanstack/query-core'
import { z } from 'zod'
import { queryCollectionOptions } from '../src/query'
import type {
DataTag,
QueryFunctionContext,
QueryObserverOptions,
} from '@tanstack/query-core'
import type { QueryCollectionConfig, QueryCollectionUtils } from '../src/query'
import type {
DeleteMutationFnParams,
Expand Down Expand Up @@ -561,6 +566,160 @@ describe(`Query collection type resolution tests`, () => {
})
})

describe(`queryOptions interoperability`, () => {
type NumberItem = {
id: number
value: string
}
type TaggedNumbersKey = DataTag<Array<string>, Array<NumberItem>, Error>
type NumberQueryObserverOptions = QueryObserverOptions<
Array<NumberItem>,
Error,
Array<NumberItem>,
Array<NumberItem>,
TaggedNumbersKey
>
const taggedNumbersQueryKey = [
`query-options-numbers`,
] as unknown as TaggedNumbersKey

it(`should accept queryOptions-like spread config with tagged queryKey`, () => {
const queryOptionsLike = {
queryKey: taggedNumbersQueryKey,
queryFn: () =>
Promise.resolve([
{ id: 1, value: `one` },
{ id: 2, value: `two` },
]),
} satisfies {
queryKey: TaggedNumbersKey
queryFn?: NumberQueryObserverOptions[`queryFn`]
}

const options = queryCollectionOptions({
...queryOptionsLike,
queryClient,
getKey: (item) => item.id,
})

expectTypeOf(options.getKey).parameters.toEqualTypeOf<[NumberItem]>()
})

it(`should accept enabled from queryOptions-like config`, () => {
const queryOptionsLike = {
queryKey: taggedNumbersQueryKey,
queryFn: () => Promise.resolve([{ id: 1, value: `one` }]),
enabled: (_query) => true,
} satisfies {
queryKey: TaggedNumbersKey
queryFn?: NumberQueryObserverOptions[`queryFn`]
enabled?: NumberQueryObserverOptions[`enabled`]
}

const options = queryCollectionOptions({
...queryOptionsLike,
queryClient,
getKey: (item) => item.id,
})

expectTypeOf(options.getKey).parameters.toEqualTypeOf<[NumberItem]>()
})

it(`should require explicit queryFn when source type marks queryFn optional`, () => {
const queryOptionsLike: {
queryKey: TaggedNumbersKey
queryFn?: (
context: QueryFunctionContext<TaggedNumbersKey>,
) => Array<NumberItem> | Promise<Array<NumberItem>>
} = {
queryKey: taggedNumbersQueryKey,
queryFn: () => Promise.resolve([{ id: 1, value: `one` }]),
}

// @ts-expect-error - interop configs require queryFn even when source type marks it optional
queryCollectionOptions({
...queryOptionsLike,
queryClient,
getKey: (item) => item.id,
})

const options = queryCollectionOptions({
...queryOptionsLike,
queryFn: (context) => queryOptionsLike.queryFn!(context),
queryClient,
getKey: (item) => item.id,
})

expectTypeOf(options.getKey).parameters.toEqualTypeOf<[NumberItem]>()
})

it(`should require select for wrapped queryOptions-like responses`, () => {
type WrappedResponse = {
total: number
items: Array<NumberItem>
}
type TaggedWrappedKey = DataTag<Array<string>, WrappedResponse, Error>
type WrappedObserverOptions = QueryObserverOptions<
WrappedResponse,
Error,
WrappedResponse,
WrappedResponse,
TaggedWrappedKey
>
const taggedWrappedQueryKey = [
`query-options-wrapped`,
] as unknown as TaggedWrappedKey

const wrappedQueryOptionsLike = {
queryKey: taggedWrappedQueryKey,
queryFn: () =>
Promise.resolve({
total: 1,
items: [{ id: 1, value: `one` }],
}),
} satisfies {
queryKey: TaggedWrappedKey
queryFn?: WrappedObserverOptions[`queryFn`]
}

// @ts-expect-error - wrapped response requires select to extract the item array
queryCollectionOptions({
...wrappedQueryOptionsLike,
queryClient,
getKey: () => 1,
})

const options = queryCollectionOptions({
...wrappedQueryOptionsLike,
select: (response) => response.items,
queryClient,
getKey: (item) => item.id,
})

expectTypeOf(options.getKey).parameters.toEqualTypeOf<[NumberItem]>()
})

it(`should still require queryFn for plain configs`, () => {
// @ts-expect-error - queryFn is required for plain configs
queryCollectionOptions<NumberItem>({
queryClient,
queryKey: [`query-options-missing-query-fn`],
getKey: (item) => item.id,
})
})

it(`should accept synchronous queryFn return values`, () => {
const options = queryCollectionOptions<NumberItem>({
queryClient,
queryKey: [`query-options-sync-query-fn`],
queryFn: () => [{ id: 1, value: `one` }],
getKey: (item) => item.id,
})

expectTypeOf(options.getKey).parameters.toEqualTypeOf<[NumberItem]>()
})
})

it(`should type collection.utils as QueryCollectionUtils after createCollection`, () => {
const collection = createCollection(
queryCollectionOptions<ExplicitType>({
Expand Down
Loading