Lesson 24 · Module 4.1 — Library-grade patterns
useQuery's nested generics, top-downOpen 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.
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.
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:
TOutput = TInput,
TError = Error, and TKey extends string = string all default, so you almost never
write the type arguments by hand. (In useQuery the no-default lead is
TQueryFnData.)ProcessorOptions extends BaseProcessor<TInput, TOutput, TError, TKey>;
AdvancedOptions intersects ProcessorOptions<…same…>. The parameter list is
constant down the chain — exactly TanStack's five-interface stack.{ data: TOutput; error: TError | null;
key: TKey }), so once they are inferred your result type is fixed — the job of
UseQueryResult<TData, TError>.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.
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.
queryFn return typeFollow 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 five interfaces — QueryOptions → QueryObserverOptions →
UseBaseQueryOptions → UseQueryOptions →
UndefinedInitialDataOptions — 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.
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.
Recall, don't re-read. (Answers reveal on click.)
useQuery signature, where does TQueryFnData actually get inferred from?TData = TQueryFnData in the default list matter when reading a call?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.
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.