Building a Type-Safe Query Key Factory
A query key factory is worth building only if the types are real — if todoKeys.detail(id) returns a readonly ['todos', 'detail', string] that the compiler understands as a prefix of todoKeys.all, not an anonymous string[]. This page is the step-by-step build of that factory: the as const object, the type helpers that surface the inferred key shapes, where the file should live, and how to wire it into hooks. It is the construction detail behind Query Key Factory Patterns; once the factory exists, Invalidating Queries With a Key Factory shows how to drive precise cache invalidation from the hierarchy you build here.
The payoff is mechanical safety: a misspelled key becomes a type error instead of a silent cache miss, and every query in a feature is addressed from one authoritative object.
Diagnostic Checklist
Build this factory if any of these describe your codebase:
- Query keys are typed inline at each
useQuerycall and no two developers spell them identically. - You hover a
queryKeyin your editor and TypeScript reportsstring[]with no literal detail. - An
invalidateQueriescall takes a hand-written array that has already drifted from the query it targets. - You cannot rename a key segment with a single refactor because the string is duplicated across files.
Step-by-Step Implementation
Step 1 — Define the entity key factory object with as const
Model the factory as an object literal where each member is either a root tuple or a function returning a tuple. Every function extends the level above it by spreading, and every returned array carries as const so the literal tuple type survives.
// features/todos/todo-keys.ts
export interface TodoFilters {
status?: 'active' | 'done';
assignee?: string;
}
export const todoKeys = {
all: ['todos'] as const,
lists: () => [...todoKeys.all, 'list'] as const,
list: (filters: TodoFilters) => [...todoKeys.lists(), filters] as const,
details: () => [...todoKeys.all, 'detail'] as const,
detail: (id: string) => [...todoKeys.details(), id] as const,
} as const;
Cache Behavior Analysis. The nested spreads make each key a literal prefix of the ones below it, so TanStack Query’s prefix matcher can later treat lists() as an ancestor of list(filters). The as const on each return value stops TypeScript from widening 'list' to string and the whole tuple to string[]; the inferred type stays readonly ['todos', 'list'], which is what preserves the ancestor relationship at the type level, not just at runtime.
Step 2 — Extract inferred key types with helper aliases
You often need the type of a key — for a function that accepts “any todo detail key,” or to type a queryFn context. Derive these from the factory with ReturnType so they can never drift from the source.
// features/todos/todo-keys.ts (continued)
export type TodoKeys = typeof todoKeys;
// The exact tuple types, inferred — never hand-written:
export type TodoListKey = ReturnType<TodoKeys['list']>; // readonly ['todos','list',TodoFilters]
export type TodoDetailKey = ReturnType<TodoKeys['detail']>; // readonly ['todos','detail',string]
export type TodoAllKey = TodoKeys['all']; // readonly ['todos']
// A function that only accepts a detail key is now compiler-checked:
export function isDetailKey(key: readonly unknown[]): key is TodoDetailKey {
return key[0] === 'todos' && key[1] === 'detail' && typeof key[2] === 'string';
}
Cache Behavior Analysis. These aliases carry no runtime cost — they compile away entirely — but they let you write helpers and predicates that the compiler validates against the real key shapes. When you later pass todoKeys.detail(id) to a function typed as TodoDetailKey, TypeScript confirms the tuple matches before the code ever reaches TanStack Query’s hasher, turning a class of runtime cache-miss bugs into compile errors.
Step 3 — Colocate the factory with its feature
Place todo-keys.ts in the same folder as the todo hooks and components. The factory is feature-private state: it should move when the feature moves and be deleted when the feature is deleted. If you later need a single object for devtools or a global invalidator, compose the per-feature factories at the app root rather than authoring keys centrally.
// app/query-keys.ts — composition root, optional
import { todoKeys } from '../features/todos/todo-keys';
import { userKeys } from '../features/users/user-keys';
export const queryKeys = {
todos: todoKeys,
users: userKeys,
} as const;
// Usage stays feature-scoped: queryKeys.todos.detail(id)
Cache Behavior Analysis. Composition does not change any key value — queryKeys.todos.detail(id) returns the identical tuple todoKeys.detail(id) does, so the cache slot is unchanged. The only effect is organizational: a single import surface for cross-feature tooling while each feature still owns and versions its own keys, avoiding the merge-conflict funnel that a hand-maintained central key file becomes.
Step 4 — Wire the factory into useQuery and useMutation
Replace every inline queryKey with a factory accessor. The hook never writes a raw array again, so the key and the query it addresses cannot drift apart.
// features/todos/use-todos.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { todoKeys, TodoFilters } from './todo-keys';
interface Todo { id: string; title: string; status: 'active' | 'done' }
export function useTodoDetail(id: string) {
return useQuery({
queryKey: todoKeys.detail(id),
queryFn: async (): Promise<Todo> => {
const res = await fetch(`/api/todos/${id}`);
if (!res.ok) throw new Error(`Fetch failed: ${res.status}`);
return res.json();
},
staleTime: 60_000,
gcTime: 5 * 60_000,
});
}
export function useRenameTodo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, title }: { id: string; title: string }) =>
fetch(`/api/todos/${id}`, { method: 'PATCH', body: JSON.stringify({ title }) }).then((r) => r.json()),
onSuccess: (_data, { id }) => {
// Refresh the one entity and every list that might display it
queryClient.invalidateQueries({ queryKey: todoKeys.detail(id), exact: true });
queryClient.invalidateQueries({ queryKey: todoKeys.lists() });
},
});
}
Cache Behavior Analysis. queryKey: todoKeys.detail(id) gives TanStack Query a readonly tuple to hash; every component calling useTodoDetail(id) with the same id hits the identical slot and shares one request. In the mutation, invalidateQueries({ queryKey: todoKeys.detail(id), exact: true }) refetches only that entity because exact: true requires a full hash match, while invalidateQueries({ queryKey: todoKeys.lists() }) fuzzy-matches every list prefix so any visible list re-syncs. Both invalidation targets are generated from the same factory the read used, so they are guaranteed to reference the same slots.
Edge Cases and Gotchas
Keys must be serializable — no class instances or functions
TanStack Query hashes keys with a deterministic serializer. A Date, Map, Set, class instance, or function has no stable serialized form, so two logically equal keys can hash to different slots (a phantom duplicate) or, worse, collide. Reduce non-primitives to primitives before they enter a key:
// Wrong — a Date has no stable hash
// todoKeys.list({ since: new Date() })
// Right — pass an ISO string
todoKeys.list({ assignee: 'ada', status: 'active' });
// For time filters, normalize first:
const since = new Date().toISOString();
Array element order is significant; object property order is not
The position of segments in the key tuple is meaningful — ['todos','detail',id] and ['detail','todos',id] are different queries. But inside a key, object property order is normalized: TanStack Query sorts object keys before serializing, so a nested filter object is safe to build in any order.
// These two resolve to the SAME cache slot:
todoKeys.list({ status: 'active', assignee: 'ada' });
todoKeys.list({ assignee: 'ada', status: 'active' });
Guard against undefined versus absent fields
{ status: undefined } and {} serialize differently, so a filter object that sometimes carries an explicit undefined forks the cache. Strip empty fields before passing them to the factory, or accept only the fields you mean to key on.
function cleanFilters(f: TodoFilters): TodoFilters {
return Object.fromEntries(Object.entries(f).filter(([, v]) => v !== undefined));
}
Common Pitfalls and Resolutions
| Observable Issue | Root Cause | Diagnostic Resolution |
|---|---|---|
Editor shows queryKey as string[], no literal detail, no autocomplete |
as const omitted on factory return values, widening the tuple |
Add as const to every returned array; the inferred readonly tuple restores literal typing |
| Two cache slots exist for one logical detail query | A Date or class instance was placed in the key and hashed inconsistently |
Reduce the key segment to a primitive (id string, toISOString()) before passing it to the accessor |
| A filter object forks the cache on every render | An explicit undefined field (or transient property) changes the serialized hash |
Normalize filters with a cleanFilters pass that drops undefined before calling list(filters) |
| A helper accepts a wrong-shaped key and fails at runtime | The helper is typed string[] instead of the inferred key alias |
Type helpers with ReturnType<typeof todoKeys.detail> so the compiler rejects mismatched keys |
Frequently Asked Questions
Can I put a Date or class instance in a query key from the factory?
No. Query keys must be JSON-serializable because TanStack Query hashes them with a deterministic serializer. A Date, Map, class instance, or function has no stable hash representation, so two structurally identical keys can hash differently or collide into one slot. Convert to a primitive first — pass date.toISOString() or the entity’s id string, never the object itself.
Does the order of properties in a filter object change the cache key?
No. TanStack Query’s hashing sorts object keys before serializing, so { status: 'active', page: 2 } and { page: 2, status: 'active' } produce the same hash and resolve to the same cache entry. Array element order in the key tuple is significant, but object property order inside a key is normalized for you, which is what makes nested filter objects safe to pass directly to list(filters).
Should the factory live in a shared keys file or next to the feature?
Colocate it with the feature. A todo-keys.ts sitting beside the todo hooks moves, refactors, and is deleted together with the code that owns those queries. A global keys barrel becomes a merge-conflict magnet and couples unrelated features. Compose the per-feature factories into a single object at the app root only if you genuinely need one surface for devtools or a global invalidator.
Related
- Query Key Factory Patterns — the parent reference covering hierarchy design, prefix matching, and why centralizing keys eliminates cache drift.
- Invalidating Queries With a Key Factory — how to drive exact and fuzzy invalidation from the factory you build here, plus predicate functions and
removeQueries. - Designing Stable Query Keys for React Query — the underlying key-stability contract that the
as constfactory enforces mechanically. - Data Normalization & Query Key Design — the top-level guide tying entity identity, key design, and cache topology together.