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 useQuery call and no two developers spell them identically.
  • You hover a queryKey in your editor and TypeScript reports string[] with no literal detail.
  • An invalidateQueries call 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.

From accessor call to typed cache slot A left-to-right pipeline. A call to todoKeys.detail('a1') passes through an as const inference stage that produces a readonly tuple type, then through the stable hash serializer, ending at a single cache slot. A note shows that object property order is normalized before hashing. Accessor call todoKeys.detail('a1') as const inference readonly ['todos', 'detail', string] Stable hash keys sorted, serialized Cache one slot Object property order is normalized at the hash stage — { a, b } and { b, a } land in the same slot. TYPE SAFETY AT AUTHORING · VALUE SAFETY AT RUNTIME
The accessor produces a literal readonly tuple at compile time and a normalized hash at runtime, so the type you see and the slot you hit always agree.

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.