diff --git a/.changeset/branded-types-fix.md b/.changeset/branded-types-fix.md new file mode 100644 index 00000000000..514dfda891f --- /dev/null +++ b/.changeset/branded-types-fix.md @@ -0,0 +1,7 @@ +--- +"@tanstack/vue-query": patch +--- + +fix(vue-query): preserve branded types in MaybeRefDeep + +Branded types (e.g. `string & { __brand: 'PostId' }`) were being recursively mapped by `MaybeRefDeep`, causing TypeScript inference failures when used in `queryKey` tuples passed from `queryOptions` to `useQuery`. This fix adds a terminal branch to `MaybeRefDeep` so that branded types are preserved as-is instead of having their properties unwrapped. diff --git a/packages/vue-query/src/__tests__/queryOptions.test-d.ts b/packages/vue-query/src/__tests__/queryOptions.test-d.ts index 481dd808fc5..31226b94a36 100644 --- a/packages/vue-query/src/__tests__/queryOptions.test-d.ts +++ b/packages/vue-query/src/__tests__/queryOptions.test-d.ts @@ -298,4 +298,50 @@ describe('queryOptions', () => { expectTypeOf(options.queryKey).not.toBeUndefined() }) + + it('should work with branded queryKey', () => { + type PostId = string & { readonly __brand: 'PostId' } + const postId = '123' as PostId + + const options = queryOptions({ + queryKey: ['post', postId], + queryFn: () => Promise.resolve({ id: postId }), + }) + + expectTypeOf(options.queryKey).not.toBeUndefined() + + // This should not error - the branded queryKey should be accepted by useQuery + const { data } = reactive(useQuery(options)) + expectTypeOf(data).toEqualTypeOf<{ id: PostId } | undefined>() + }) + + it('should work with branded queryKey inside MaybeRefOrGetter', () => { + type PostId = string & { readonly __brand: 'PostId' } + const postId = '123' as PostId + + // Test 1: simple queryOptions with branded string directly in tuple (like existing test) + const simpleOptions = queryOptions({ + queryKey: ['post', postId as PostId], + queryFn: () => Promise.resolve({ id: postId as PostId }), + }) + const { data: simpleData } = reactive(useQuery(simpleOptions)) + expectTypeOf(simpleData).toEqualTypeOf<{ id: PostId } | undefined>() + + // Test 2: branded string inside object inside tuple + const nestedOptions = queryOptions({ + queryKey: ['post', { postId: postId as PostId }], + queryFn: () => Promise.resolve({ id: postId as PostId }), + }) + const { data: nestedData } = reactive(useQuery(nestedOptions)) + expectTypeOf(nestedData).toEqualTypeOf<{ id: PostId } | undefined>() + + // Test 3: inline branded queryKey + const { data: inlineData } = reactive( + useQuery({ + queryKey: ['post', { postId: postId as PostId }], + queryFn: () => Promise.resolve({ id: postId as PostId }), + }), + ) + expectTypeOf(inlineData).toEqualTypeOf<{ id: PostId } | undefined>() + }) }) diff --git a/packages/vue-query/src/types.ts b/packages/vue-query/src/types.ts index 8ef5664b29f..6879b1c323c 100644 --- a/packages/vue-query/src/types.ts +++ b/packages/vue-query/src/types.ts @@ -31,11 +31,13 @@ export type MaybeRefOrGetter = MaybeRef | (() => T) export type MaybeRefDeep = MaybeRef< T extends Function ? T - : T extends object - ? { - [Property in keyof T]: MaybeRefDeep - } - : T + : T extends { __brand: infer _ } + ? T + : T extends object + ? { + [Property in keyof T]: MaybeRefDeep + } + : T > export type NoUnknown = Equal extends true ? never : T