Lesson 24 · Module 4.1 — Library-grade patterns

Reading useQuery's nested generics, top-down

Open useQuery's type and you meet a wall of generics: four type parameters — the <…> slots — passed down through five nested option types. It looks like something you'd have to memorise. You don't. In real use there is exactly one slot you care about: what your fetch function (the queryFn) returns — TanStack names it TQueryFnData. TypeScript works that one out from your code on its own, and every other slot is just a default that flows from it. So the skill isn't decoding all five layers; it's spotting the single piece you actually provide and letting the rest fall into place as you read top-down.

The reading move

When a library type has N parameters, find the one that gets inferred from your call and treat the rest as defaults that flow from it. Here that one is TQueryFnData — what your queryFn returns. Read inward from there; don't read every layer left-to-right.

First, the same architecture with plain names

The real signature is hard mostly because of the names (TQueryFnData, UndefinedInitialDataOptions…). So here is the identical shape, built from scratch with self-explanatory names: four type parameters threaded through a base interface, a second interface that extends it, an intersection on top, and finally a function — the same layering TanStack uses, minus the jargon:

// Step 1 — the deepest type: four params, plainly named
interface BaseProcessor<TInput, TOutput, TError, TKey> {
  input: TInput;
  process: (data: TInput) => Promise<TOutput>;   // async fn; its return drives TOutput
  onError?: (error: TError) => void;
  key: TKey;
}

// Step 2 — a layer that EXTENDS it, forwarding the same four params
interface ProcessorOptions<TInput, TOutput = TInput, TError = Error, TKey extends string = string>
  extends BaseProcessor<TInput, TOutput, TError, TKey> {
  transform?: (raw: TInput) => TOutput;
  enabled?: boolean;
}

// Step 3 — one more layer, via intersection (&)
type AdvancedOptions<TInput, TOutput = TInput, TError = Error, TKey extends string = string> =
  ProcessorOptions<TInput, TOutput, TError, TKey> & {
    cache?: boolean;
    retry?: number;
  };

// Step 4 — the function: same four params; results surface in the return type
function transform<TInput, TOutput = TInput, TError = Error, TKey extends string = string>(
  options: AdvancedOptions<TInput, TOutput, TError, TKey>
): { data: TOutput; error: TError | null; key: TKey } {
  return {} as any;
}

Three things to notice — they are the entire pattern, and every one carries over to useQuery:

The inference comes from the call, just like useQuery. Three fields supply it here — input fixes TInput, process’s return fixes TOutput, and key fixes TKey — while TError, which nothing provides, falls back to its default Error:

const r = transform({
  input: 42,
  process: async (data) => data.toFixed(2),
  key: "job-1",
});
// inferred:  TInput = number (from input), TOutput = string (from process's return),
//            TKey = "job-1" (from key); TError unspecified → defaults to Error.
// result:    r = { data: string; error: Error | null; key: "job-1" }

(useQuery is tighter still: the single queryFn return drives TQueryFnData and TData defaults off it — but the reading skill is identical.) Now read the real signature below the same way: find the parameter the call infers, let the defaults flow off it, and skip every layer that only forwards.

The signature, decoded once

Here is the declaration as it ships in the library. Don't parse it word by word — find the parameter list and read what each default points at:

declare function useQuery<
  TQueryFnData = unknown,        // what your queryFn returns — INFERRED from your call
  TError = DefaultError,           // error shape — defaults, you rarely set it
  TData = TQueryFnData,            // what `data` ends up as — defaults to TQueryFnData
  TQueryKey extends QueryKey = QueryKey
>(options: UndefinedInitialDataOptions<TQueryFnData, TError, TData, TQueryKey>)
  : UseQueryResult<NoInfer<TData>, TError>;

Read the defaults as a dependency chain: TData defaults to TQueryFnData; TQueryFnData defaults to unknown but is actually filled by inference. So if you only provide a queryFn, fixing TQueryFnData fixes TData, which fixes the type of result.data. One inference, whole chain resolved.

Where the inference enters: the queryFn return type

Follow TQueryFnData down through the layers. It rides through every options interface unchanged until it reaches the field that produces it:

// the field, deep in QueryOptions:
queryFn?: QueryFunction<TQueryFnData, TQueryKey, TPageParam> | SkipToken;

// and QueryFunction — note where T sits: it's the RETURN type
type QueryFunction<T = unknown, ...> =
  (context: QueryFunctionContext<...>) => T | Promise<T>;
//                                       └─ T is inferred from what you return ─┘

So when you write the call, TS reads your queryFn's return type, sets T = that, and T is TQueryFnData. The Promise<T> arm means an async function unwraps automatically:

useQuery({
  queryKey: ["plan", userId],                          // → TQueryKey
  queryFn: (): Promise<{ currentPlan: PricePlan }> => ...  // → TQueryFnData = { currentPlan: PricePlan }
  // no `select`, so → TData = TQueryFnData
});
// result.data is { currentPlan: PricePlan } | undefined

The layers exist for composition, not for you to memorise

The five interfaces — QueryOptionsQueryObserverOptionsUseBaseQueryOptionsUseQueryOptionsUndefinedInitialDataOptions — each extends or intersects the one below and re-passes the same four type parameters down. That's the whole pattern:

Each layer is a thin wrapper that adds one or two fields and forwards <TQueryFnData, TError, TData, TQueryKey> to its parent. The parameters are constant; only the field set grows.

So to read it: confirm a parameter is just being passed through (it appears identically in extends Parent<…same…>), and skip to the layer where it's finally used in a field type. You don't trace all five — you trace one parameter to its one meaningful use.

Don't confuse TQueryFnData with TData

TQueryFnData is the raw fetch result; TData is what you read from result.data. They're equal until you pass a select transform — then TData becomes select's return type while TQueryFnData stays the raw shape. Two parameters, two jobs.

Check yourself

Recall, don't re-read. (Answers reveal on click.)

In the useQuery signature, where does TQueryFnData actually get inferred from?
Why does TData = TQueryFnData in the default list matter when reading a call?
The five layered options interfaces mostly exist to…

Primary source

The canonical reference for these generics is the library's own typing guide, TanStack Query — TypeScript, and the useQuery reference. Best way to feel it: paste a useQuery call into the TS Playground with the types installed and hover result.data — then add a select and watch TData diverge from TQueryFnData.

Try it yourself

Staring at a library signature with too many type parameters? Ask which one is inferred from your call, and which just default off it — then trace that one inward. Next lesson (4.2) uses the same parameter-tracking skill on a builder whose generic accumulates with every method call.