Skip to content

persister slot drags TQueryKey into inference, breaking Register.queryKey + DataTag consumers #10600

@neefrehman

Description

@neefrehman

Summary

PR #10510 (5.100.5) correctly fixed persister inference for TQueryFnData when queryFn takes an explicit parameter. However, removing the NoInfer wrappers on persister has a side effect: for codebases that use the documented Register.queryKey augmentation and define a wrapper whose return re-brands the queryKey with DataTag, the branded return becomes un-assignable in contravariant slots (mockReturnValue, ReturnType<> containers, etc.) — even though no call site changed.

A narrowly-scoped structural tweak (NoInfer<TQueryKey> on persister only) would preserve #10510's benefit while restoring behaviour for augmented-Register users.

Minimal repro

Requires:

  1. Register.queryKey augmented with a narrowed constraint.
  2. A wrapper around queryOptions whose return re-brands queryKey with DataTag (to keep queryClient.getQueryData / setQueryData inferable on that key).
  3. A literal assigned into the wrapper's return type.
// 1. The documented Register augmentation.
import '@tanstack/react-query';

declare module '@tanstack/react-query' {
  interface Register {
    queryKey: readonly ['todos' | 'users' | 'settings', ...unknown[]];
  }
}
// 2. A wrapper that brands the returned queryKey with DataTag.
import {
  type DataTag,
  type QueryKey,
  queryOptions,
  type UndefinedInitialDataOptions,
  type UseQueryOptions,
} from '@tanstack/react-query';

function appQueryOptions<
  TData,
  const TPrefix extends QueryKey = QueryKey,
  TKey extends QueryKey = [...TPrefix, string],
>({
  name: _n,
  prefix: _p,
  ...options
}: Omit<UseQueryOptions<TData, Error, TData, TKey>, 'queryFn' | 'queryKey'> & {
  prefix: TPrefix;
  name: string;
}): UndefinedInitialDataOptions<TData, Error, TData, TKey> & {
  queryKey: DataTag<TKey, TData, Error>;
} {
  return queryOptions({
    queryKey: [] as unknown as TKey,
    queryFn: async () => ({}) as TData,
    ...options,
  }) as never;
}
// 3. A literal assigned into the wrapper's return type.
// 5.100.1: compiles. 5.100.5: TS2322 on queryKey.
const _a: ReturnType<typeof appQueryOptions<unknown[]>> = {
  queryKey: ['todos', 'list'],
  queryFn: async () => [],
};

Error on 5.100.5:

Type '["todos", "list"]' is not assignable to type
'readonly ["todos" | "users" | "settings", ...unknown[]]
  & { [dataTagSymbol]: unknown[]; [dataTagErrorSymbol]: Error; }'.

Why it changed

Under the old signature, NoInfer<TQueryKey> in persister kept that slot out of TQueryKey inference, so TQueryKey was determined solely from the queryKey field (or the wrapper's own generics). The returned DataTag<TQueryKey, ...> brand then sat on the caller's literal type, which was trivially assignable back to itself.

After #10510, when the caller supplies no persister, TS still considers the persister slot during TQueryKey inference. With Register.queryKey augmented to a non-default constraint, the default TQueryKey extends QueryKey = QueryKey resolves to the augmented type, and the inferred TQueryKey widens to the constraint rather than the literal. The returned DataTag is then branded on the wider type, and a plain literal tuple can no longer satisfy both the augmented constraint and the phantom brand.

This is a side effect of the fix interacting with Register-augmented keys, not the fix itself being wrong.

Consumer-side workaround

For anyone wrapping queryOptions and returning UndefinedInitialDataOptions<..., TKey> & { queryKey: DataTag<TKey, ...> }: Omit persister from the wrapper's return type. The persister slot is the contravariant position dragging TQueryKey inference around, and most wrappers don't plumb it through anyway:

-}): UndefinedInitialDataOptions<TData, Error, TData, TKey> & {
+}): Omit<UndefinedInitialDataOptions<TData, Error, TData, TKey>, 'persister'> & {
   queryKey: DataTag<TKey, TData, Error>;
 }

This preserves DataTag inference for getQueryData / setQueryData without touching any call sites.

Suggested upstream action

Any of:

  • Structural tweak so persister's generics decouple: persister?: QueryPersister<TQueryFnData, NoInfer<TQueryKey>, TPageParam>. TQueryFnData still participates (as fix(query-core): stop wrapping persister generics in NoInfer #10510 needs), TQueryKey does not. Worth running against queryOptions.test-d.tsx before committing.
  • Changelog note in 5.100.5 calling out that consumers using Register.queryKey with a non-default constraint may see new errors at DataTag-branded wrapper return sites, with a pointer to the workaround above.
  • Documentation on the Register.queryKey augmentation page noting the interaction with DataTag-tagged returns.

Versions

  • @tanstack/react-query 5.100.5
  • @tanstack/query-core 5.100.5
  • TypeScript 5.x

I've opened #10601 with a suggested fix.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions