Skip to content

Commit 1dc5758

Browse files
authored
feat: typed query key (#6119)
* feat: tag the queryKey returned from queryOptions so that it knows about types of the queryFn * feat: overloads for getQueryData / setQueryData to infer types from queryOptions * test: type-tests for queryClient to avoid regressions * refactor: rename * refactor(types): simplify options interestingly, this also solves other types issues we had 🤯 * test: more tests for queryOptions * fix(types): fetchInfiniteQuery should work when getNextPageParam is passed even if pages isn't * feat(types): infiniteQueryOptions * test: this should work * fix(types): fix typing for setQueryData when only a value is passed by making sure TS doesn't infer types from there also, move from overloads to conditional return types * refactor: re-use Updater type * chore: fix tests * chore: do an actual assertion * refactor: remove ValidateQueryOptions as it's not used anymore * docs: queryOptions
1 parent ba96f0b commit 1dc5758

File tree

15 files changed

+613
-79
lines changed

15 files changed

+613
-79
lines changed

docs/react/typescript.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,27 @@ useQuery(groupOptions())
169169
queryClient.prefetchQuery(groupOptions())
170170
```
171171

172+
Further, the `queryKey` returned from `queryOptions` knows about the `queryFn` associated with it, and we can leverage that type information to make functions like `queryClient.getQueryData` aware of those types as well:
173+
174+
```ts
175+
function groupOptions() {
176+
return queryOptions({
177+
queryKey: ['groups'],
178+
queryFn: fetchGroups,
179+
staleTime: 5 * 1000,
180+
})
181+
}
182+
183+
const data = queryClient.getQueryData(groupOptions().queryKey)
184+
// ^? const data: Group[] | undefined
185+
```
186+
187+
Without `queryOptions`, the type of `data` would be `unknown`, unless we'd pass a generic to it:
188+
189+
```ts
190+
const data = queryClient.getQueryData<Group[]>(['groups'])
191+
```
192+
172193
[//]: # 'TypingQueryOptions'
173194
[//]: # 'Materials'
174195

packages/query-core/src/queryClient.ts

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { focusManager } from './focusManager'
1111
import { onlineManager } from './onlineManager'
1212
import { notifyManager } from './notifyManager'
1313
import { infiniteQueryBehavior } from './infiniteQueryBehavior'
14+
import type { DataTag, NoInfer } from './types'
1415
import type { QueryState } from './query'
1516
import type {
1617
CancelOptions,
@@ -107,10 +108,18 @@ export class QueryClient {
107108
return this.#mutationCache.findAll({ ...filters, status: 'pending' }).length
108109
}
109110

110-
getQueryData<TQueryFnData = unknown>(
111-
queryKey: QueryKey,
112-
): TQueryFnData | undefined {
113-
return this.#queryCache.find<TQueryFnData>({ queryKey })?.state.data
111+
getQueryData<
112+
TQueryFnData = unknown,
113+
TaggedQueryKey extends QueryKey = QueryKey,
114+
TInferredQueryFnData = TaggedQueryKey extends DataTag<
115+
unknown,
116+
infer TaggedValue
117+
>
118+
? TaggedValue
119+
: TQueryFnData,
120+
>(queryKey: TaggedQueryKey): TInferredQueryFnData | undefined
121+
getQueryData(queryKey: QueryKey) {
122+
return this.#queryCache.find({ queryKey })?.state.data
114123
}
115124

116125
ensureQueryData<
@@ -137,12 +146,24 @@ export class QueryClient {
137146
})
138147
}
139148

140-
setQueryData<TQueryFnData>(
141-
queryKey: QueryKey,
142-
updater: Updater<TQueryFnData | undefined, TQueryFnData | undefined>,
149+
setQueryData<
150+
TQueryFnData = unknown,
151+
TaggedQueryKey extends QueryKey = QueryKey,
152+
TInferredQueryFnData = TaggedQueryKey extends DataTag<
153+
unknown,
154+
infer TaggedValue
155+
>
156+
? TaggedValue
157+
: TQueryFnData,
158+
>(
159+
queryKey: TaggedQueryKey,
160+
updater: Updater<
161+
NoInfer<TInferredQueryFnData> | undefined,
162+
NoInfer<TInferredQueryFnData> | undefined
163+
>,
143164
options?: SetDataOptions,
144-
): TQueryFnData | undefined {
145-
const query = this.#queryCache.find<TQueryFnData>({ queryKey })
165+
): TInferredQueryFnData | undefined {
166+
const query = this.#queryCache.find<TInferredQueryFnData>({ queryKey })
146167
const prevData = query?.state.data
147168
const data = functionalUpdate(updater, prevData)
148169

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import { QueryClient } from '../queryClient'
2+
import { doNotExecute } from './utils'
3+
import type { Equal, Expect } from './utils'
4+
import type { DataTag, InfiniteData } from '../types'
5+
6+
describe('getQueryData', () => {
7+
it('should be typed if key is tagged', () => {
8+
doNotExecute(() => {
9+
const queryKey = ['key'] as DataTag<Array<string>, number>
10+
const queryClient = new QueryClient()
11+
const data = queryClient.getQueryData(queryKey)
12+
13+
const result: Expect<Equal<typeof data, number | undefined>> = true
14+
return result
15+
})
16+
})
17+
18+
it('should infer unknown if key is not tagged', () => {
19+
doNotExecute(() => {
20+
const queryKey = ['key'] as const
21+
const queryClient = new QueryClient()
22+
const data = queryClient.getQueryData(queryKey)
23+
24+
const result: Expect<Equal<typeof data, unknown>> = true
25+
return result
26+
})
27+
})
28+
29+
it('should infer passed generic if passed', () => {
30+
doNotExecute(() => {
31+
const queryKey = ['key'] as const
32+
const queryClient = new QueryClient()
33+
const data = queryClient.getQueryData<number>(queryKey)
34+
35+
const result: Expect<Equal<typeof data, number | undefined>> = true
36+
return result
37+
})
38+
})
39+
40+
it('should only allow Arrays to be passed', () => {
41+
doNotExecute(() => {
42+
const queryKey = 'key' as const
43+
const queryClient = new QueryClient()
44+
// @ts-expect-error TS2345: Argument of type 'string' is not assignable to parameter of type 'QueryKey'
45+
return queryClient.getQueryData(queryKey)
46+
})
47+
})
48+
})
49+
50+
describe('setQueryData', () => {
51+
it('updater should be typed if key is tagged', () => {
52+
doNotExecute(() => {
53+
const queryKey = ['key'] as DataTag<Array<string>, number>
54+
const queryClient = new QueryClient()
55+
const data = queryClient.setQueryData(queryKey, (prev) => {
56+
const result: Expect<Equal<typeof prev, number | undefined>> = true
57+
return result ? prev : 1
58+
})
59+
60+
const result: Expect<Equal<typeof data, number | undefined>> = true
61+
return result
62+
})
63+
})
64+
65+
it('value should be typed if key is tagged', () => {
66+
doNotExecute(() => {
67+
const queryKey = ['key'] as DataTag<Array<string>, number>
68+
const queryClient = new QueryClient()
69+
70+
// @ts-expect-error value should be a number
71+
queryClient.setQueryData(queryKey, '1')
72+
73+
// @ts-expect-error value should be a number
74+
queryClient.setQueryData(queryKey, () => '1')
75+
76+
const data = queryClient.setQueryData(queryKey, 1)
77+
78+
const result: Expect<Equal<typeof data, number | undefined>> = true
79+
return result
80+
})
81+
})
82+
83+
it('should infer unknown for updater if key is not tagged', () => {
84+
doNotExecute(() => {
85+
const queryKey = ['key'] as const
86+
const queryClient = new QueryClient()
87+
const data = queryClient.setQueryData(queryKey, (prev) => {
88+
const result: Expect<Equal<typeof prev, unknown>> = true
89+
return result ? prev : 1
90+
})
91+
92+
const result: Expect<Equal<typeof data, unknown>> = true
93+
return result
94+
})
95+
})
96+
97+
it('should infer unknown for value if key is not tagged', () => {
98+
doNotExecute(() => {
99+
const queryKey = ['key'] as const
100+
const queryClient = new QueryClient()
101+
const data = queryClient.setQueryData(queryKey, 'foo')
102+
103+
const result: Expect<Equal<typeof data, unknown>> = true
104+
return result
105+
})
106+
})
107+
108+
it('should infer passed generic if passed', () => {
109+
doNotExecute(() => {
110+
const queryKey = ['key'] as const
111+
const queryClient = new QueryClient()
112+
const data = queryClient.setQueryData<string>(queryKey, (prev) => {
113+
const result: Expect<Equal<typeof prev, string | undefined>> = true
114+
return result ? prev : '1'
115+
})
116+
117+
const result: Expect<Equal<typeof data, string | undefined>> = true
118+
return result
119+
})
120+
})
121+
122+
it('should infer passed generic for value', () => {
123+
doNotExecute(() => {
124+
const queryKey = ['key'] as const
125+
const queryClient = new QueryClient()
126+
const data = queryClient.setQueryData<string>(queryKey, 'foo')
127+
128+
const result: Expect<Equal<typeof data, string | undefined>> = true
129+
return result
130+
})
131+
})
132+
})
133+
134+
describe('fetchInfiniteQuery', () => {
135+
it('should allow passing pages', () => {
136+
doNotExecute(async () => {
137+
const data = await new QueryClient().fetchInfiniteQuery({
138+
queryKey: ['key'],
139+
queryFn: () => Promise.resolve('string'),
140+
getNextPageParam: () => 1,
141+
initialPageParam: 1,
142+
pages: 5,
143+
})
144+
145+
const result: Expect<Equal<typeof data, InfiniteData<string, number>>> =
146+
true
147+
return result
148+
})
149+
})
150+
151+
it('should not allow passing getNextPageParam without pages', () => {
152+
doNotExecute(async () => {
153+
return new QueryClient().fetchInfiniteQuery({
154+
queryKey: ['key'],
155+
queryFn: () => Promise.resolve('string'),
156+
initialPageParam: 1,
157+
getNextPageParam: () => 1,
158+
})
159+
})
160+
})
161+
162+
it('should not allow passing pages without getNextPageParam', () => {
163+
doNotExecute(async () => {
164+
// @ts-expect-error Property 'getNextPageParam' is missing
165+
return new QueryClient().fetchInfiniteQuery({
166+
queryKey: ['key'],
167+
queryFn: () => Promise.resolve('string'),
168+
initialPageParam: 1,
169+
pages: 5,
170+
})
171+
})
172+
})
173+
})

packages/query-core/src/tests/utils.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,3 +66,13 @@ export function setIsServer(isServer: boolean) {
6666
})
6767
}
6868
}
69+
70+
export const doNotExecute = (_func: () => void) => true
71+
72+
export type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends <
73+
T,
74+
>() => T extends Y ? 1 : 2
75+
? true
76+
: false
77+
78+
export type Expect<T extends true> = T

packages/query-core/src/types.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import type { QueryFilters, QueryTypeFilter } from './utils'
77
import type { QueryCache } from './queryCache'
88
import type { MutationCache } from './mutationCache'
99

10-
type NoInfer<T> = [T][T extends any ? 0 : never]
10+
export type NoInfer<T> = [T][T extends any ? 0 : never]
1111

1212
export interface Register {
1313
// defaultError: Error
@@ -23,6 +23,11 @@ export type DefaultError = Register extends {
2323

2424
export type QueryKey = ReadonlyArray<unknown>
2525

26+
export declare const dataTagSymbol: unique symbol
27+
export type DataTag<Type, Value> = Type & {
28+
[dataTagSymbol]: Value
29+
}
30+
2631
export type QueryFunction<
2732
T = unknown,
2833
TQueryKey extends QueryKey = QueryKey,
@@ -76,9 +81,10 @@ export type PlaceholderDataFunction<
7681
previousQuery: Query<TQueryFnData, TError, TQueryData, TQueryKey> | undefined,
7782
) => TQueryData | undefined
7883

79-
export type QueriesPlaceholderDataFunction<TQueryData> = () =>
80-
| TQueryData
81-
| undefined
84+
export type QueriesPlaceholderDataFunction<TQueryData> = (
85+
previousData: undefined,
86+
previousQuery: undefined,
87+
) => TQueryData | undefined
8288

8389
export type QueryKeyHashFunction<TQueryKey extends QueryKey> = (
8490
queryKey: TQueryKey,
@@ -386,7 +392,7 @@ export interface FetchQueryOptions<
386392
}
387393

388394
type FetchInfiniteQueryPages<TQueryFnData = unknown, TPageParam = unknown> =
389-
| { pages?: never; getNextPageParam?: never }
395+
| { pages?: never }
390396
| {
391397
pages: number
392398
getNextPageParam: GetNextPageParamFunction<TPageParam, TQueryFnData>

packages/query-core/src/utils.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -56,11 +56,7 @@ export interface MutationFilters {
5656
status?: MutationStatus
5757
}
5858

59-
export type DataUpdateFunction<TInput, TOutput> = (input: TInput) => TOutput
60-
61-
export type Updater<TInput, TOutput> =
62-
| TOutput
63-
| DataUpdateFunction<TInput, TOutput>
59+
export type Updater<TInput, TOutput> = TOutput | ((input: TInput) => TOutput)
6460

6561
export type QueryTypeFilter = 'all' | 'active' | 'inactive'
6662

@@ -77,7 +73,7 @@ export function functionalUpdate<TInput, TOutput>(
7773
input: TInput,
7874
): TOutput {
7975
return typeof updater === 'function'
80-
? (updater as DataUpdateFunction<TInput, TOutput>)(input)
76+
? (updater as (_: TInput) => TOutput)(input)
8177
: updater
8278
}
8379

0 commit comments

Comments
 (0)