diff --git a/.changeset/query-options-interop-types.md b/.changeset/query-options-interop-types.md new file mode 100644 index 000000000..60efbcf2a --- /dev/null +++ b/.changeset/query-options-interop-types.md @@ -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` instead of requiring `Promise`. +- 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`. diff --git a/docs/collections/query-collection.md b/docs/collections/query-collection.md index cf13291a8..38fecd4b4 100644 --- a/docs/collections/query-collection.md +++ b/docs/collections/query-collection.md @@ -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> + }, +}) + +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 diff --git a/packages/query-db-collection/src/query.ts b/packages/query-db-collection/src/query.ts index 920cdb69a..c5feecbdf 100644 --- a/packages/query-db-collection/src/query.ts +++ b/packages/query-db-collection/src/query.ts @@ -59,9 +59,9 @@ type TQueryKeyBuilder = (opts: LoadSubsetOptions) => TQueryKey */ export interface QueryCollectionConfig< T extends object = object, - TQueryFn extends (context: QueryFunctionContext) => Promise = ( + TQueryFn extends (context: QueryFunctionContext) => any = ( context: QueryFunctionContext, - ) => Promise, + ) => any, TError = unknown, TQueryKey extends QueryKey = QueryKey, TKey extends string | number = string | number, @@ -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, - ) => Promise> - ? (context: QueryFunctionContext) => Promise> + ) => Promise> | Array + ? (context: QueryFunctionContext) => Promise> | Array : TQueryFn /* Function that extracts array items from wrapped API responses (e.g metadata, pagination) */ select?: (data: TQueryData) => Array @@ -83,33 +83,39 @@ export interface QueryCollectionConfig< // Query-specific options /** Whether the query should automatically run (default: true) */ - enabled?: boolean - refetchInterval?: QueryObserverOptions< - Array, + enabled?: QueryObserverOptions< + TQueryData, TError, Array, + TQueryData, + TQueryKey + >[`enabled`] + refetchInterval?: QueryObserverOptions< + TQueryData, + TError, Array, + TQueryData, TQueryKey >[`refetchInterval`] retry?: QueryObserverOptions< - Array, + TQueryData, TError, Array, - Array, + TQueryData, TQueryKey >[`retry`] retryDelay?: QueryObserverOptions< - Array, + TQueryData, TError, Array, - Array, + TQueryData, TQueryKey >[`retryDelay`] staleTime?: QueryObserverOptions< - Array, + TQueryData, TError, Array, - Array, + TQueryData, TQueryKey >[`staleTime`] @@ -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) => Promise, + TQueryFn extends (context: QueryFunctionContext) => any, TError = unknown, TQueryKey extends QueryKey = QueryKey, TKey extends string | number = string | number, @@ -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) => Promise = ( + TQueryFn extends (context: QueryFunctionContext) => any = ( context: QueryFunctionContext, - ) => Promise, + ) => any, TError = unknown, TQueryKey extends QueryKey = QueryKey, TKey extends string | number = string | number, @@ -469,7 +475,7 @@ export function queryCollectionOptions< InferSchemaOutput, ( context: QueryFunctionContext, - ) => Promise>>, + ) => Array> | Promise>>, TError, TQueryKey, TKey, @@ -501,7 +507,7 @@ export function queryCollectionOptions< >( config: QueryCollectionConfig< T, - (context: QueryFunctionContext) => Promise>, + (context: QueryFunctionContext) => Array | Promise>, TError, TQueryKey, TKey @@ -519,7 +525,10 @@ export function queryCollectionOptions< } export function queryCollectionOptions( - config: QueryCollectionConfig>, + config: QueryCollectionConfig< + Record, + (context: QueryFunctionContext) => any + >, ): CollectionConfig< Record, string | number, @@ -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() diff --git a/packages/query-db-collection/tests/query.test-d.ts b/packages/query-db-collection/tests/query.test-d.ts index 4f53e2c85..5722445b0 100644 --- a/packages/query-db-collection/tests/query.test-d.ts +++ b/packages/query-db-collection/tests/query.test-d.ts @@ -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, @@ -561,6 +566,160 @@ describe(`Query collection type resolution tests`, () => { }) }) + describe(`queryOptions interoperability`, () => { + type NumberItem = { + id: number + value: string + } + type TaggedNumbersKey = DataTag, Array, Error> + type NumberQueryObserverOptions = QueryObserverOptions< + Array, + Error, + Array, + Array, + 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, + ) => Array | Promise> + } = { + 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 + } + type TaggedWrappedKey = DataTag, 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({ + queryClient, + queryKey: [`query-options-missing-query-fn`], + getKey: (item) => item.id, + }) + }) + + it(`should accept synchronous queryFn return values`, () => { + const options = queryCollectionOptions({ + 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({