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
49 changes: 49 additions & 0 deletions .changeset/fix-query-collection-config-type.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
---
"@tanstack/query-db-collection": patch
---

Fix TypeScript type resolution for QueryCollectionConfig when using queryCollectionOptions without a schema.

Previously, the `QueryCollectionConfig` interface extended `BaseCollectionConfig`, but TypeScript failed to resolve inherited properties like `getKey`, `onInsert`, `onUpdate`, etc. when the interface contained a conditional type for the `queryFn` property. This caused type errors when trying to use `queryCollectionOptions` without a schema.

**Before:**

```typescript
// This would fail with TypeScript error:
// "Property 'getKey' does not exist on type 'QueryCollectionConfig<...>'"
const options = queryCollectionOptions({
queryKey: ["todos"],
queryFn: async (): Promise<Array<Todo>> => {
const response = await fetch("/api/todos")
return response.json()
},
queryClient,
getKey: (item) => item.id, // ❌ Type error
})
```

**After:**

```typescript
// Now works correctly!
const options = queryCollectionOptions({
queryKey: ["todos"],
queryFn: async (): Promise<Array<Todo>> => {
const response = await fetch("/api/todos")
return response.json()
},
queryClient,
getKey: (item) => item.id, // ✅ Works
})

const collection = createCollection(options) // ✅ Fully typed
```

**Changes:**

- Changed `QueryCollectionConfig` to use `Omit<BaseCollectionConfig<...>, 'onInsert' | 'onUpdate' | 'onDelete'>` pattern
- This matches the approach used by `ElectricCollectionConfig` and `PowerSyncCollectionConfig` for consistency
- Explicitly declares mutation handlers with custom return type `{ refetch?: boolean }`
- This resolves the TypeScript type resolution issue with conditional types
- All functionality remains the same - this is purely a type-level fix
- Added test cases to verify the no-schema use case works correctly
41 changes: 33 additions & 8 deletions packages/query-db-collection/src/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,13 @@ import type {
BaseCollectionConfig,
ChangeMessage,
CollectionConfig,
DeleteMutationFn,
DeleteMutationFnParams,
InsertMutationFn,
InsertMutationFnParams,
LoadSubsetOptions,
SyncConfig,
UpdateMutationFn,
UpdateMutationFnParams,
UtilsRecord,
} from "@tanstack/db"
Expand Down Expand Up @@ -66,7 +69,10 @@ export interface QueryCollectionConfig<
TKey extends string | number = string | number,
TSchema extends StandardSchemaV1 = never,
TQueryData = Awaited<ReturnType<TQueryFn>>,
> extends BaseCollectionConfig<T, TKey, TSchema> {
> extends Omit<
BaseCollectionConfig<T, TKey, TSchema, UtilsRecord, any>,
`onInsert` | `onUpdate` | `onDelete`
> {
/** The query key used by TanStack Query to identify this query */
queryKey: TQueryKey | TQueryKeyBuilder<TQueryKey>
/** Function that fetches data from the server. Must return the complete collection state */
Expand Down Expand Up @@ -133,6 +139,30 @@ export interface QueryCollectionConfig<
* }
*/
meta?: Record<string, unknown>

/**
* Optional asynchronous handler called when items are inserted into the collection
* Allows persisting changes to a backend and optionally controlling refetch behavior
* @param params Object containing transaction and collection information
* @returns Promise that can return { refetch?: boolean } to control whether to refetch after insert
*/
onInsert?: InsertMutationFn<T, TKey, UtilsRecord, { refetch?: boolean }>

/**
* Optional asynchronous handler called when items are updated in the collection
* Allows persisting changes to a backend and optionally controlling refetch behavior
* @param params Object containing transaction and collection information
* @returns Promise that can return { refetch?: boolean } to control whether to refetch after update
*/
onUpdate?: UpdateMutationFn<T, TKey, UtilsRecord, { refetch?: boolean }>

/**
* Optional asynchronous handler called when items are deleted from the collection
* Allows persisting changes to a backend and optionally controlling refetch behavior
* @param params Object containing transaction and collection information
* @returns Promise that can return { refetch?: boolean } to control whether to refetch after delete
*/
onDelete?: DeleteMutationFn<T, TKey, UtilsRecord, { refetch?: boolean }>
}

/**
Expand Down Expand Up @@ -518,13 +548,8 @@ export function queryCollectionOptions<
}

export function queryCollectionOptions(
config: QueryCollectionConfig<Record<string, unknown>>
): CollectionConfig<
Record<string, unknown>,
string | number,
never,
QueryCollectionUtils
> & {
config: QueryCollectionConfig<any, any, any, any, any, any, any>
): CollectionConfig<any, any, any, any> & {
utils: QueryCollectionUtils
} {
const {
Expand Down
50 changes: 50 additions & 0 deletions packages/query-db-collection/tests/query.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,56 @@ describe(`Query collection type resolution tests`, () => {
})
})

describe(`no schema with type inference from queryFn`, () => {
it(`should work without schema when queryFn has explicit return type`, () => {
interface Todo {
id: string
title: string
completed: boolean
}

const options = queryCollectionOptions({
queryKey: [`todos-no-schema`],
queryFn: async (): Promise<Array<Todo>> => {
return [] as Array<Todo>
},
queryClient,
getKey: (item) => item.id,
})

const collection = createCollection(options)

// Verify types are correctly inferred
expectTypeOf(options.getKey).parameters.toEqualTypeOf<[Todo]>()
expectTypeOf(collection.toArray).toEqualTypeOf<Array<Todo>>()
})

it(`should work without schema when queryFn return type is inferred`, () => {
interface Todo {
id: string
title: string
completed: boolean
}

const fetchTodos = async (): Promise<Array<Todo>> => {
return [] as Array<Todo>
}

const options = queryCollectionOptions({
queryKey: [`todos-no-schema-inferred`],
queryFn: fetchTodos,
queryClient,
getKey: (item) => item.id,
})

const collection = createCollection(options)

// Verify types are correctly inferred
expectTypeOf(options.getKey).parameters.toEqualTypeOf<[Todo]>()
expectTypeOf(collection.toArray).toEqualTypeOf<Array<Todo>>()
})
})

describe(`select type inference`, () => {
it(`queryFn type inference`, () => {
const dataSchema = z.object({
Expand Down
Loading