Skip to content

Commit 049d8a5

Browse files
ho8aesamwillis
andauthored
feat: Add type inference from queryFn return type (#403)
Co-authored-by: Sam Willis <[email protected]>
1 parent 9a5a20c commit 049d8a5

File tree

5 files changed

+222
-30
lines changed

5 files changed

+222
-30
lines changed

.changeset/fast-poets-read.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@tanstack/query-db-collection": patch
3+
---
4+
5+
Add type inference of the collection type from the query collection config `queryFn` return type

packages/query-db-collection/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
"description": "TanStack Query collection for TanStack DB",
44
"version": "0.2.3",
55
"dependencies": {
6-
"@tanstack/db": "workspace:*"
6+
"@tanstack/db": "workspace:*",
7+
"@standard-schema/spec": "^1.0.0"
78
},
89
"devDependencies": {
910
"@tanstack/query-core": "^5.0.5",

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

Lines changed: 124 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -24,69 +24,114 @@ import type {
2424
UpdateMutationFnParams,
2525
UtilsRecord,
2626
} from "@tanstack/db"
27+
import type { StandardSchemaV1 } from "@standard-schema/spec"
2728

2829
// Re-export for external use
2930
export type { SyncOperation } from "./manual-sync"
3031

32+
// Schema output type inference helper (matches electric.ts pattern)
33+
type InferSchemaOutput<T> = T extends StandardSchemaV1
34+
? StandardSchemaV1.InferOutput<T> extends object
35+
? StandardSchemaV1.InferOutput<T>
36+
: Record<string, unknown>
37+
: Record<string, unknown>
38+
39+
// QueryFn return type inference helper
40+
type InferQueryFnOutput<TQueryFn> = TQueryFn extends (
41+
context: QueryFunctionContext<any>
42+
) => Promise<Array<infer TItem>>
43+
? TItem extends object
44+
? TItem
45+
: Record<string, unknown>
46+
: Record<string, unknown>
47+
48+
// Type resolution system with priority order (matches electric.ts pattern)
49+
type ResolveType<
50+
TExplicit extends object | unknown = unknown,
51+
TSchema extends StandardSchemaV1 = never,
52+
TQueryFn = unknown,
53+
> = unknown extends TExplicit
54+
? [TSchema] extends [never]
55+
? InferQueryFnOutput<TQueryFn>
56+
: InferSchemaOutput<TSchema>
57+
: TExplicit
58+
3159
/**
3260
* Configuration options for creating a Query Collection
33-
* @template TItem - The type of items stored in the collection
61+
* @template TExplicit - The explicit type of items stored in the collection (highest priority)
62+
* @template TSchema - The schema type for validation and type inference (second priority)
63+
* @template TQueryFn - The queryFn type for inferring return type (third priority)
3464
* @template TError - The type of errors that can occur during queries
3565
* @template TQueryKey - The type of the query key
3666
*/
3767
export interface QueryCollectionConfig<
38-
TItem extends object,
68+
TExplicit extends object = object,
69+
TSchema extends StandardSchemaV1 = never,
70+
TQueryFn extends (
71+
context: QueryFunctionContext<any>
72+
) => Promise<Array<any>> = (
73+
context: QueryFunctionContext<any>
74+
) => Promise<Array<any>>,
3975
TError = unknown,
4076
TQueryKey extends QueryKey = QueryKey,
4177
> {
4278
/** The query key used by TanStack Query to identify this query */
4379
queryKey: TQueryKey
4480
/** Function that fetches data from the server. Must return the complete collection state */
45-
queryFn: (context: QueryFunctionContext<TQueryKey>) => Promise<Array<TItem>>
81+
queryFn: TQueryFn extends (
82+
context: QueryFunctionContext<TQueryKey>
83+
) => Promise<Array<any>>
84+
? TQueryFn
85+
: (
86+
context: QueryFunctionContext<TQueryKey>
87+
) => Promise<Array<ResolveType<TExplicit, TSchema, TQueryFn>>>
88+
4689
/** The TanStack Query client instance */
4790
queryClient: QueryClient
4891

4992
// Query-specific options
5093
/** Whether the query should automatically run (default: true) */
5194
enabled?: boolean
5295
refetchInterval?: QueryObserverOptions<
53-
Array<TItem>,
96+
Array<ResolveType<TExplicit, TSchema, TQueryFn>>,
5497
TError,
55-
Array<TItem>,
56-
Array<TItem>,
98+
Array<ResolveType<TExplicit, TSchema, TQueryFn>>,
99+
Array<ResolveType<TExplicit, TSchema, TQueryFn>>,
57100
TQueryKey
58101
>[`refetchInterval`]
59102
retry?: QueryObserverOptions<
60-
Array<TItem>,
103+
Array<ResolveType<TExplicit, TSchema, TQueryFn>>,
61104
TError,
62-
Array<TItem>,
63-
Array<TItem>,
105+
Array<ResolveType<TExplicit, TSchema, TQueryFn>>,
106+
Array<ResolveType<TExplicit, TSchema, TQueryFn>>,
64107
TQueryKey
65108
>[`retry`]
66109
retryDelay?: QueryObserverOptions<
67-
Array<TItem>,
110+
Array<ResolveType<TExplicit, TSchema, TQueryFn>>,
68111
TError,
69-
Array<TItem>,
70-
Array<TItem>,
112+
Array<ResolveType<TExplicit, TSchema, TQueryFn>>,
113+
Array<ResolveType<TExplicit, TSchema, TQueryFn>>,
71114
TQueryKey
72115
>[`retryDelay`]
73116
staleTime?: QueryObserverOptions<
74-
Array<TItem>,
117+
Array<ResolveType<TExplicit, TSchema, TQueryFn>>,
75118
TError,
76-
Array<TItem>,
77-
Array<TItem>,
119+
Array<ResolveType<TExplicit, TSchema, TQueryFn>>,
120+
Array<ResolveType<TExplicit, TSchema, TQueryFn>>,
78121
TQueryKey
79122
>[`staleTime`]
80123

81124
// Standard Collection configuration properties
82125
/** Unique identifier for the collection */
83126
id?: string
84127
/** Function to extract the unique key from an item */
85-
getKey: CollectionConfig<TItem>[`getKey`]
128+
getKey: CollectionConfig<ResolveType<TExplicit, TSchema, TQueryFn>>[`getKey`]
86129
/** Schema for validating items */
87-
schema?: CollectionConfig<TItem>[`schema`]
88-
sync?: CollectionConfig<TItem>[`sync`]
89-
startSync?: CollectionConfig<TItem>[`startSync`]
130+
schema?: TSchema
131+
sync?: CollectionConfig<ResolveType<TExplicit, TSchema, TQueryFn>>[`sync`]
132+
startSync?: CollectionConfig<
133+
ResolveType<TExplicit, TSchema, TQueryFn>
134+
>[`startSync`]
90135

91136
// Direct persistence handlers
92137
/**
@@ -129,7 +174,7 @@ export interface QueryCollectionConfig<
129174
* }
130175
* }
131176
*/
132-
onInsert?: InsertMutationFn<TItem>
177+
onInsert?: InsertMutationFn<ResolveType<TExplicit, TSchema, TQueryFn>>
133178

134179
/**
135180
* Optional asynchronous handler function called before an update operation
@@ -182,7 +227,7 @@ export interface QueryCollectionConfig<
182227
* return { refetch: false } // Skip automatic refetch since we handled it manually
183228
* }
184229
*/
185-
onUpdate?: UpdateMutationFn<TItem>
230+
onUpdate?: UpdateMutationFn<ResolveType<TExplicit, TSchema, TQueryFn>>
186231

187232
/**
188233
* Optional asynchronous handler function called before a delete operation
@@ -228,8 +273,7 @@ export interface QueryCollectionConfig<
228273
* return { refetch: false } // Skip automatic refetch since we handled it manually
229274
* }
230275
*/
231-
onDelete?: DeleteMutationFn<TItem>
232-
// TODO type returning { refetch: boolean }
276+
onDelete?: DeleteMutationFn<ResolveType<TExplicit, TSchema, TQueryFn>>
233277

234278
/**
235279
* Metadata to pass to the query.
@@ -289,16 +333,55 @@ export interface QueryCollectionUtils<
289333
* Creates query collection options for use with a standard Collection.
290334
* This integrates TanStack Query with TanStack DB for automatic synchronization.
291335
*
336+
* Supports automatic type inference following the priority order:
337+
* 1. Explicit type (highest priority)
338+
* 2. Schema inference (second priority)
339+
* 3. QueryFn return type inference (third priority)
340+
* 4. Fallback to Record<string, unknown>
341+
*
342+
* @template TExplicit - The explicit type of items in the collection (highest priority)
343+
* @template TSchema - The schema type for validation and type inference (second priority)
344+
* @template TQueryFn - The queryFn type for inferring return type (third priority)
345+
* @template TError - The type of errors that can occur during queries
346+
* @template TQueryKey - The type of the query key
347+
* @template TKey - The type of the item keys
348+
* @template TInsertInput - The type accepted for insert operations
292349
* @param config - Configuration options for the Query collection
293350
* @returns Collection options with utilities for direct writes and manual operations
294351
*
295352
* @example
296-
* // Basic usage
353+
* // Type inferred from queryFn return type (NEW!)
354+
* const todosCollection = createCollection(
355+
* queryCollectionOptions({
356+
* queryKey: ['todos'],
357+
* queryFn: async () => {
358+
* const response = await fetch('/api/todos')
359+
* return response.json() as Todo[] // Type automatically inferred!
360+
* },
361+
* queryClient,
362+
* getKey: (item) => item.id, // item is typed as Todo
363+
* })
364+
* )
365+
*
366+
* @example
367+
* // Explicit type (highest priority)
368+
* const todosCollection = createCollection<Todo>(
369+
* queryCollectionOptions({
370+
* queryKey: ['todos'],
371+
* queryFn: async () => fetch('/api/todos').then(r => r.json()),
372+
* queryClient,
373+
* getKey: (item) => item.id,
374+
* })
375+
* )
376+
*
377+
* @example
378+
* // Schema inference (second priority)
297379
* const todosCollection = createCollection(
298380
* queryCollectionOptions({
299381
* queryKey: ['todos'],
300382
* queryFn: async () => fetch('/api/todos').then(r => r.json()),
301383
* queryClient,
384+
* schema: todoSchema, // Type inferred from schema
302385
* getKey: (item) => item.id,
303386
* })
304387
* )
@@ -324,16 +407,28 @@ export interface QueryCollectionUtils<
324407
* )
325408
*/
326409
export function queryCollectionOptions<
327-
TItem extends object,
410+
TExplicit extends object = object,
411+
TSchema extends StandardSchemaV1 = never,
412+
TQueryFn extends (
413+
context: QueryFunctionContext<any>
414+
) => Promise<Array<any>> = (
415+
context: QueryFunctionContext<any>
416+
) => Promise<Array<any>>,
328417
TError = unknown,
329418
TQueryKey extends QueryKey = QueryKey,
330419
TKey extends string | number = string | number,
331-
TInsertInput extends object = TItem,
420+
TInsertInput extends object = ResolveType<TExplicit, TSchema, TQueryFn>,
332421
>(
333-
config: QueryCollectionConfig<TItem, TError, TQueryKey>
334-
): CollectionConfig<TItem> & {
335-
utils: QueryCollectionUtils<TItem, TKey, TInsertInput>
422+
config: QueryCollectionConfig<TExplicit, TSchema, TQueryFn, TError, TQueryKey>
423+
): CollectionConfig<ResolveType<TExplicit, TSchema, TQueryFn>> & {
424+
utils: QueryCollectionUtils<
425+
ResolveType<TExplicit, TSchema, TQueryFn>,
426+
TKey,
427+
TInsertInput
428+
>
336429
} {
430+
type TItem = ResolveType<TExplicit, TSchema, TQueryFn>
431+
337432
const {
338433
queryKey,
339434
queryFn,

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

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,4 +186,92 @@ describe(`Query collection type resolution tests`, () => {
186186
// Test that the getKey function has the correct parameter type
187187
expectTypeOf(queryOptions.getKey).parameters.toEqualTypeOf<[UserType]>()
188188
})
189+
190+
describe(`QueryFn type inference`, () => {
191+
interface TodoType {
192+
id: string
193+
title: string
194+
completed: boolean
195+
}
196+
197+
it(`should infer types from queryFn return type`, () => {
198+
const options = queryCollectionOptions({
199+
queryClient,
200+
queryKey: [`queryfn-inference`],
201+
queryFn: async (): Promise<Array<TodoType>> => {
202+
return [] as Array<TodoType>
203+
},
204+
getKey: (item) => item.id,
205+
})
206+
207+
// Should infer TodoType from queryFn
208+
expectTypeOf(options.getKey).parameters.toEqualTypeOf<[TodoType]>()
209+
})
210+
211+
it(`should prioritize explicit type over queryFn`, () => {
212+
interface UserType {
213+
id: string
214+
name: string
215+
}
216+
217+
const options = queryCollectionOptions<UserType>({
218+
queryClient,
219+
queryKey: [`explicit-priority`],
220+
queryFn: async (): Promise<Array<TodoType>> => {
221+
return [] as Array<TodoType>
222+
},
223+
getKey: (item) => item.id,
224+
})
225+
226+
// Should use explicit UserType, not TodoType from queryFn
227+
expectTypeOf(options.getKey).parameters.toEqualTypeOf<[UserType]>()
228+
})
229+
230+
it(`should prioritize schema over queryFn`, () => {
231+
const userSchema = z.object({
232+
id: z.string(),
233+
name: z.string(),
234+
email: z.string(),
235+
})
236+
237+
const options = queryCollectionOptions({
238+
queryClient,
239+
queryKey: [`schema-priority`],
240+
queryFn: async (): Promise<Array<z.infer<typeof userSchema>>> => {
241+
return [] as Array<z.infer<typeof userSchema>>
242+
},
243+
schema: userSchema,
244+
getKey: (item) => item.id,
245+
})
246+
247+
// Should use schema type, not TodoType from queryFn
248+
type ExpectedType = z.infer<typeof userSchema>
249+
expectTypeOf(options.getKey).parameters.toEqualTypeOf<[ExpectedType]>()
250+
})
251+
252+
it(`should maintain backward compatibility with explicit types`, () => {
253+
const options = queryCollectionOptions<TodoType>({
254+
queryClient,
255+
queryKey: [`backward-compat`],
256+
queryFn: async () => [] as Array<TodoType>,
257+
getKey: (item) => item.id,
258+
})
259+
260+
expectTypeOf(options.getKey).parameters.toEqualTypeOf<[TodoType]>()
261+
})
262+
263+
it(`should work with collection creation`, () => {
264+
const options = queryCollectionOptions({
265+
queryClient,
266+
queryKey: [`collection-test`],
267+
queryFn: async (): Promise<Array<TodoType>> => {
268+
return [] as Array<TodoType>
269+
},
270+
getKey: (item) => item.id,
271+
})
272+
273+
const collection = createCollection(options)
274+
expectTypeOf(collection.toArray).toEqualTypeOf<Array<TodoType>>()
275+
})
276+
})
189277
})

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)