From 7042fa982376e60f50bba78cab79fc9bea446a5b Mon Sep 17 00:00:00 2001 From: Ousama Ben Younes Date: Sat, 25 Apr 2026 11:00:31 +0000 Subject: [PATCH 1/3] 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 --- .changeset/vue-query-narrow-result-type.md | 7 ++ docs/framework/vue/typescript.md | 2 + .../src/__tests__/useQuery.test-d.ts | 70 +++++++++++++++++++ packages/vue-query/src/useBaseQuery.ts | 29 +++++--- 4 files changed, 98 insertions(+), 10 deletions(-) create mode 100644 .changeset/vue-query-narrow-result-type.md diff --git a/.changeset/vue-query-narrow-result-type.md b/.changeset/vue-query-narrow-result-type.md new file mode 100644 index 00000000000..a631c71f9dd --- /dev/null +++ b/.changeset/vue-query-narrow-result-type.md @@ -0,0 +1,7 @@ +--- +'@tanstack/vue-query': patch +--- + +fix(vue-query): preserve discriminated union narrowing in `UseBaseQueryReturnType` + +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. diff --git a/docs/framework/vue/typescript.md b/docs/framework/vue/typescript.md index 7cf70dbc4f3..4eb43dc0e94 100644 --- a/docs/framework/vue/typescript.md +++ b/docs/framework/vue/typescript.md @@ -68,6 +68,8 @@ if (isSuccess) { [typescript playground](https://www.typescriptlang.org/play?#code/JYWwDg9gTgLgBAbzgVwM4FMCKz1QJ5wC+cAZlBCHAOQACMAhgHaoMDGA1gPQBuOAtAEcc+KgFgAUKEixEcKOnqsYwbuiKlylKr3RUA3BImsIzeEgAm9BgBo4wVAGVkrVulSp1AXjkKlK9AAUaFjCeAEA2lQwbjBUALq2AQCUcJ4AfHAACpr26AB08qgQADaqAQCsSVWGkiRwAfZOLm6oKQgScJ1wlgwSnJydAHoA-BKEEkA) +> **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)`. + [//]: # 'TypeNarrowing' [//]: # 'TypingError' diff --git a/packages/vue-query/src/__tests__/useQuery.test-d.ts b/packages/vue-query/src/__tests__/useQuery.test-d.ts index be9013e222b..07a9aa68cf7 100644 --- a/packages/vue-query/src/__tests__/useQuery.test-d.ts +++ b/packages/vue-query/src/__tests__/useQuery.test-d.ts @@ -2,6 +2,7 @@ import { describe, expectTypeOf, it } from 'vitest' import { computed, reactive, ref } from 'vue-demi' import { queryKey, sleep } from '@tanstack/query-test-utils' import { queryOptions, useQuery } from '..' +import type { Ref } from 'vue-demi' import type { OmitKeyof, UseQueryOptions } from '..' describe('useQuery', () => { @@ -268,6 +269,75 @@ describe('useQuery', () => { }) }) + // Regression coverage for #9244 — narrowing across the discriminated + // result union from useQuery() under the patterns users actually write. + describe('issue #9244 — narrowing without reactive()', () => { + it('useQuery() return preserves the discriminated union (no reactive())', () => { + const key = queryKey() + + const query = useQuery({ + queryKey: key, + queryFn: () => sleep(0).then(() => 'Some data'), + }) + + // Whole-result narrowing requires reactive() because the discriminator + // sits inside `Ref`. The `data` ref itself is still a + // discriminated union of `Ref | Ref` (matches the + // shape documented in docs/framework/vue/typescript.md). + expectTypeOf(query.data).toEqualTypeOf | Ref>() + }) + + it('data.value narrows after a direct undefined check', () => { + const key = queryKey() + + const { data } = useQuery({ + queryKey: key, + queryFn: () => sleep(0).then(() => 'Some data'), + }) + + // This is the recommended pattern when `reactive()` is not used: + // narrow on `.value !== undefined` rather than relying on `isSuccess`. + if (data.value !== undefined) { + expectTypeOf(data.value).toEqualTypeOf() + expectTypeOf(data).toEqualTypeOf>() + } + }) + + it('reactive() preserves narrowing across destructured properties', () => { + const key = queryKey() + + // Destructuring directly from `useQuery()` (without `reactive()`) + // breaks cross-property narrowing because each ref is independent — + // wrapping in `reactive()` flattens the refs and keeps the + // discriminated union linkage. + const { data, isSuccess } = reactive( + useQuery({ + queryKey: key, + queryFn: () => sleep(0).then(() => 'Some data'), + }), + ) + + if (isSuccess) { + expectTypeOf(data).toEqualTypeOf() + } + }) + + it('reactive() narrows on status discriminator', () => { + const key = queryKey() + + const { data, status } = reactive( + useQuery({ + queryKey: key, + queryFn: () => sleep(0).then(() => 'Some data'), + }), + ) + + if (status === 'success') { + expectTypeOf(data).toEqualTypeOf() + } + }) + }) + describe('accept ref options', () => { it('should accept ref options', () => { const options = ref({ diff --git a/packages/vue-query/src/useBaseQuery.ts b/packages/vue-query/src/useBaseQuery.ts index f5c444b3ae9..950769ed924 100644 --- a/packages/vue-query/src/useBaseQuery.ts +++ b/packages/vue-query/src/useBaseQuery.ts @@ -24,20 +24,29 @@ import type { UseQueryOptions } from './useQuery' import type { UseInfiniteQueryOptions } from './useInfiniteQuery' import type { MaybeRefOrGetter } from './types' +// Distributive over `TResult` so each member of the discriminated union +// (success / pending / error / placeholder / refetch-error / loading-error) +// produces its own ref-mapped object, instead of collapsing into a single +// `{ data: Ref, ... }`. This preserves narrowing through +// `reactive()` and direct property access on the un-destructured result. +// See packages/vue-query/src/__tests__/useQuery.test-d.ts for the contracts +// this preserves (and the destructure-without-reactive limitation). export type UseBaseQueryReturnType< TData, TError, TResult = QueryObserverResult, -> = { - [K in keyof TResult]: K extends - | 'fetchNextPage' - | 'fetchPreviousPage' - | 'refetch' - ? TResult[K] - : Ref[K]> -} & { - suspense: () => Promise -} +> = TResult extends unknown + ? { + [K in keyof TResult]: K extends + | 'fetchNextPage' + | 'fetchPreviousPage' + | 'refetch' + ? TResult[K] + : Ref[K]> + } & { + suspense: () => Promise> + } + : never type UseQueryOptionsGeneric< TQueryFnData, From b69f0795a81bc3247d418dfccd77ed813f25b7a9 Mon Sep 17 00:00:00 2001 From: Ousama Ben Younes Date: Sat, 25 Apr 2026 22:34:37 +0000 Subject: [PATCH 2/3] fix(vue-query): parameterize suspense return type by TResult MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous shape pinned `suspense` to `Promise>` inside the distributive conditional, so awaiting `suspense()` lost type precision when `UseBaseQueryReturnType` was instantiated with `DefinedQueryObserverResult` (data became `TData | undefined` again) or `InfiniteQueryObserverResult` (no `fetchNextPage`/`hasNextPage`/`data.pages`). Lifting `suspense` outside the distributive arm and typing it as `Promise` keeps the per-property mapping distributive but lets the suspense result carry the parameterized observer type — matching the runtime already returned by `observer.fetchOptimistic()` / `getOptimisticResult()`. --- packages/vue-query/src/useBaseQuery.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/vue-query/src/useBaseQuery.ts b/packages/vue-query/src/useBaseQuery.ts index 950769ed924..5da492f6ee6 100644 --- a/packages/vue-query/src/useBaseQuery.ts +++ b/packages/vue-query/src/useBaseQuery.ts @@ -35,7 +35,7 @@ export type UseBaseQueryReturnType< TData, TError, TResult = QueryObserverResult, -> = TResult extends unknown +> = (TResult extends unknown ? { [K in keyof TResult]: K extends | 'fetchNextPage' @@ -43,10 +43,10 @@ export type UseBaseQueryReturnType< | 'refetch' ? TResult[K] : Ref[K]> - } & { - suspense: () => Promise> } - : never + : never) & { + suspense: () => Promise +} type UseQueryOptionsGeneric< TQueryFnData, From b8e8cea61d51b02fb5ec16b7a8e664435aea57f9 Mon Sep 17 00:00:00 2001 From: Ousama Ben Younes Date: Sun, 26 Apr 2026 13:41:07 +0000 Subject: [PATCH 3/3] test(vue-query): pin suspense() Promise parameterization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a regression test asserting that `useQuery(...).suspense()` resolves to `Promise>` (parameterized by `TResult` on `UseBaseQueryReturnType`) and that awaiting it preserves the discriminated union narrowing on `isSuccess` / `isError`. Closes the verification gap noted on #10580 — the parameterization was previously only validated via tsc build, not by an explicit `expectTypeOf` assertion. --- .../src/__tests__/useQuery.test-d.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/packages/vue-query/src/__tests__/useQuery.test-d.ts b/packages/vue-query/src/__tests__/useQuery.test-d.ts index 07a9aa68cf7..0678a5bc521 100644 --- a/packages/vue-query/src/__tests__/useQuery.test-d.ts +++ b/packages/vue-query/src/__tests__/useQuery.test-d.ts @@ -3,6 +3,7 @@ import { computed, reactive, ref } from 'vue-demi' import { queryKey, sleep } from '@tanstack/query-test-utils' import { queryOptions, useQuery } from '..' import type { Ref } from 'vue-demi' +import type { QueryObserverResult } from '@tanstack/query-core' import type { OmitKeyof, UseQueryOptions } from '..' describe('useQuery', () => { @@ -336,6 +337,30 @@ describe('useQuery', () => { expectTypeOf(data).toEqualTypeOf() } }) + + it('suspense() returns Promise parameterized by TResult', async () => { + const key = queryKey() + + const query = useQuery({ + queryKey: key, + queryFn: () => sleep(0).then(() => 'Some data'), + }) + + // Pinning the resolved type guards the `suspense: () => Promise` + // parameterization on `UseBaseQueryReturnType` against accidental + // collapse to `Promise` or `Promise>`. + expectTypeOf(query.suspense()).toEqualTypeOf< + Promise> + >() + + const result = await query.suspense() + // Awaiting the promise must preserve the discriminated union so + // narrowing on `isSuccess` reduces `data` to the queryFn return type. + if (result.isSuccess) { + expectTypeOf(result.data).toEqualTypeOf() + expectTypeOf(result.error).toEqualTypeOf() + } + }) }) describe('accept ref options', () => {