diff --git a/.changeset/branded-types-fix.md b/.changeset/branded-types-fix.md new file mode 100644 index 0000000000..514dfda891 --- /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 8b1ee84c1e..c53857b7c7 100644 --- a/packages/vue-query/src/__tests__/queryOptions.test-d.ts +++ b/packages/vue-query/src/__tests__/queryOptions.test-d.ts @@ -362,4 +362,59 @@ 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() + + 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 + + const simpleOptions = queryOptions({ + queryKey: ['post', postId], + queryFn: () => Promise.resolve({ id: postId }), + }) + const { data: simpleData } = reactive(useQuery(simpleOptions)) + expectTypeOf(simpleData).toEqualTypeOf<{ id: PostId } | undefined>() + + const nestedOptions = queryOptions({ + queryKey: ['post', { postId }], + queryFn: () => Promise.resolve({ id: postId }), + }) + const { data: nestedData } = reactive(useQuery(nestedOptions)) + expectTypeOf(nestedData).toEqualTypeOf<{ id: PostId } | undefined>() + + const { data: inlineData } = reactive( + useQuery({ + queryKey: ['post', { postId }], + queryFn: () => Promise.resolve({ id: postId }), + }), + ) + expectTypeOf(inlineData).toEqualTypeOf<{ id: PostId } | undefined>() + + const brandedObjectOptions = queryOptions({ + queryKey: [ + 'post', + { __brand: 'post' as const, postId: ref('123') }, + ] as const, + queryFn: () => Promise.resolve({ id: postId }), + }) + const { data: brandedObjectData } = reactive(useQuery(brandedObjectOptions)) + expectTypeOf(brandedObjectData).toEqualTypeOf<{ id: PostId } | undefined>() + expectTypeOf(brandedObjectOptions.queryKey[1].postId).toMatchTypeOf< + string | ReturnType + >() + }) }) diff --git a/packages/vue-query/src/types.ts b/packages/vue-query/src/types.ts index 8ef5664b29..2bcdb60d2a 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 Primitive + ? T + : T extends object + ? { + [Property in keyof T]: MaybeRefDeep + } + : T > export type NoUnknown = Equal extends true ? never : T