Skip to content

Commit 7375ffb

Browse files
fix(vue-query): preserve discriminated union narrowing in UseBaseQueryReturnType (#9244)
Make the mapped type explicitly distributive over each variant of QueryObserverResult, and lock in the narrowing patterns that work without reactive() (direct data.value !== undefined check) versus those that require reactive() (narrowing via isSuccess / status). Fixes #9244 Generated by Claude Code Vibe coded by ousamabenyounes Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 05cf2bc commit 7375ffb

4 files changed

Lines changed: 98 additions & 10 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@tanstack/vue-query': patch
3+
---
4+
5+
fix(vue-query): preserve discriminated union narrowing in `UseBaseQueryReturnType`
6+
7+
Make the mapped type explicitly distributive over each variant of `QueryObserverResult`, and document the narrowing patterns that work without `reactive()` (direct `data.value !== undefined` checks) versus those that require `reactive()` (narrowing via `isSuccess`/`status`). Adds type-test coverage for the issue scenario.

docs/framework/vue/typescript.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ if (isSuccess) {
6868

6969
[typescript playground](https://www.typescriptlang.org/play?#code/JYWwDg9gTgLgBAbzgVwM4FMCKz1QJ5wC+cAZlBCHAOQACMAhgHaoMDGA1gPQBuOAtAEcc+KgFgAUKEixEcKOnqsYwbuiKlylKr3RUA3BImsIzeEgAm9BgBo4wVAGVkrVulSp1AXjkKlK9AAUaFjCeAEA2lQwbjBUALq2AQCUcJ4AfHAACpr26AB08qgQADaqAQCsSVWGkiRwAfZOLm6oKQgScJ1wlgwSnJydAHoA-BKEEkA)
7070

71+
> **Note:** Wrapping `useQuery(...)` in `reactive(...)` is required to narrow `data` from a discriminator like `isSuccess` or `status`. Destructuring directly from `useQuery(...)` produces independent refs, and TypeScript cannot propagate narrowing across separate refs — `if (isSuccess.value)` will not narrow `data.value` from `T | undefined` to `T`. If you cannot use `reactive()`, narrow the value ref directly with `if (data.value !== undefined)`.
72+
7173
[//]: # 'TypeNarrowing'
7274
[//]: # 'TypingError'
7375

packages/vue-query/src/__tests__/useQuery.test-d.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, expectTypeOf, it } from 'vitest'
22
import { computed, reactive, ref } from 'vue-demi'
33
import { queryKey, sleep } from '@tanstack/query-test-utils'
44
import { queryOptions, useQuery } from '..'
5+
import type { Ref } from 'vue-demi'
56
import type { OmitKeyof, UseQueryOptions } from '..'
67

78
describe('useQuery', () => {
@@ -268,6 +269,75 @@ describe('useQuery', () => {
268269
})
269270
})
270271

272+
// Regression coverage for #9244 — narrowing across the discriminated
273+
// result union from useQuery() under the patterns users actually write.
274+
describe('issue #9244 — narrowing without reactive()', () => {
275+
it('useQuery() return preserves the discriminated union (no reactive())', () => {
276+
const key = queryKey()
277+
278+
const query = useQuery({
279+
queryKey: key,
280+
queryFn: () => sleep(0).then(() => 'Some data'),
281+
})
282+
283+
// Whole-result narrowing requires reactive() because the discriminator
284+
// sits inside `Ref<boolean>`. The `data` ref itself is still a
285+
// discriminated union of `Ref<string> | Ref<undefined>` (matches the
286+
// shape documented in docs/framework/vue/typescript.md).
287+
expectTypeOf(query.data).toEqualTypeOf<Ref<string> | Ref<undefined>>()
288+
})
289+
290+
it('data.value narrows after a direct undefined check', () => {
291+
const key = queryKey()
292+
293+
const { data } = useQuery({
294+
queryKey: key,
295+
queryFn: () => sleep(0).then(() => 'Some data'),
296+
})
297+
298+
// This is the recommended pattern when `reactive()` is not used:
299+
// narrow on `.value !== undefined` rather than relying on `isSuccess`.
300+
if (data.value !== undefined) {
301+
expectTypeOf(data.value).toEqualTypeOf<string>()
302+
expectTypeOf(data).toEqualTypeOf<Ref<string>>()
303+
}
304+
})
305+
306+
it('reactive() preserves narrowing across destructured properties', () => {
307+
const key = queryKey()
308+
309+
// Destructuring directly from `useQuery()` (without `reactive()`)
310+
// breaks cross-property narrowing because each ref is independent —
311+
// wrapping in `reactive()` flattens the refs and keeps the
312+
// discriminated union linkage.
313+
const { data, isSuccess } = reactive(
314+
useQuery({
315+
queryKey: key,
316+
queryFn: () => sleep(0).then(() => 'Some data'),
317+
}),
318+
)
319+
320+
if (isSuccess) {
321+
expectTypeOf(data).toEqualTypeOf<string>()
322+
}
323+
})
324+
325+
it('reactive() narrows on status discriminator', () => {
326+
const key = queryKey()
327+
328+
const { data, status } = reactive(
329+
useQuery({
330+
queryKey: key,
331+
queryFn: () => sleep(0).then(() => 'Some data'),
332+
}),
333+
)
334+
335+
if (status === 'success') {
336+
expectTypeOf(data).toEqualTypeOf<string>()
337+
}
338+
})
339+
})
340+
271341
describe('accept ref options', () => {
272342
it('should accept ref options', () => {
273343
const options = ref({

packages/vue-query/src/useBaseQuery.ts

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,20 +24,29 @@ import type { UseQueryOptions } from './useQuery'
2424
import type { UseInfiniteQueryOptions } from './useInfiniteQuery'
2525
import type { MaybeRefOrGetter } from './types'
2626

27+
// Distributive over `TResult` so each member of the discriminated union
28+
// (success / pending / error / placeholder / refetch-error / loading-error)
29+
// produces its own ref-mapped object, instead of collapsing into a single
30+
// `{ data: Ref<TData | undefined>, ... }`. This preserves narrowing through
31+
// `reactive()` and direct property access on the un-destructured result.
32+
// See packages/vue-query/src/__tests__/useQuery.test-d.ts for the contracts
33+
// this preserves (and the destructure-without-reactive limitation).
2734
export type UseBaseQueryReturnType<
2835
TData,
2936
TError,
3037
TResult = QueryObserverResult<TData, TError>,
31-
> = {
32-
[K in keyof TResult]: K extends
33-
| 'fetchNextPage'
34-
| 'fetchPreviousPage'
35-
| 'refetch'
36-
? TResult[K]
37-
: Ref<Readonly<TResult>[K]>
38-
} & {
39-
suspense: () => Promise<TResult>
40-
}
38+
> = TResult extends unknown
39+
? {
40+
[K in keyof TResult]: K extends
41+
| 'fetchNextPage'
42+
| 'fetchPreviousPage'
43+
| 'refetch'
44+
? TResult[K]
45+
: Ref<Readonly<TResult>[K]>
46+
} & {
47+
suspense: () => Promise<QueryObserverResult<TData, TError>>
48+
}
49+
: never
4150

4251
type UseQueryOptionsGeneric<
4352
TQueryFnData,

0 commit comments

Comments
 (0)