Developer Experience
End-to-End Type Safety
Tags are not magic strings — they are typed values produced by a defineTags() factory. TypeScript checks every tag reference from the query meta all the way to the server invalidation call.
What to watch for
- ▶defineTags() returns a typed tree — autocomplete works on every branch
- ▶Misspelling a tag name is a compile error, not a silent miss
- ▶meta.tags accepts only valid tag arrays — wrong shape is rejected
- ▶Server-side invalidateTags() shares the same types — one contract, two environments
T
Type-safe tags
defineTags() produces a tree of typed callables. No string literals anywhere.
A
Autocomplete
appTags.todos. shows list, summary, byId in your IDE. Typos = compile error.
S
Shared contract
One tag-contract.ts file. Imported by both client useQuery and server invalidateTags.
H
Hierarchical
Invalidating appTags.todos() cascades to all children: list, summary, byId.
1. defineTags() — typed tag tree
import { function defineTags<T extends ValidTagFactoryTree<T>>(factories: T): ResolvedTagTree<T>Builds a typed, callable tag tree from a plain factory object. Each leaf becomes a
tag factory stamped with its `TAG_PATH`; each branch becomes a callable `TagGroup` that
expands to all descendant leaves when invoked without arguments.defineTags } from "@tanstack-tools/query-tags";
export const const appTags: ResolvedTagTree<{
readonly todos: {
readonly list: () => string[];
readonly summary: () => string[];
readonly byId: (id: number) => (string | number)[];
};
readonly notes: {
readonly list: () => string[];
};
}>
appTags = defineTags<{
readonly todos: {
readonly list: () => string[];
readonly summary: () => string[];
readonly byId: (id: number) => (string | number)[];
};
readonly notes: {
readonly list: () => string[];
};
}>(factories: {
readonly todos: {
readonly list: () => string[];
readonly summary: () => string[];
readonly byId: (id: number) => (string | number)[];
};
readonly notes: {
readonly list: () => string[];
};
}): ResolvedTagTree<{
readonly todos: {
readonly list: () => string[];
readonly summary: () => string[];
readonly byId: (id: number) => (string | number)[];
};
readonly notes: {
readonly list: () => string[];
};
}>
Builds a typed, callable tag tree from a plain factory object. Each leaf becomes a
tag factory stamped with its `TAG_PATH`; each branch becomes a callable `TagGroup` that
expands to all descendant leaves when invoked without arguments.defineTags({
todos: {
readonly list: () => string[];
readonly summary: () => string[];
readonly byId: (id: number) => (string | number)[];
}
todos: {
list: () => string[]list: () => ["todos"],
summary: () => string[]summary: () => ["todos", "summary"],
byId: (id: number) => (string | number)[]byId: (id: numberid: number) => ["todo", id: numberid],
},
notes: {
readonly list: () => string[];
}
notes: {
list: () => string[]list: () => ["notes"],
},
} as type const = {
readonly todos: {
readonly list: () => string[];
readonly summary: () => string[];
readonly byId: (id: number) => (string | number)[];
};
readonly notes: {
readonly list: () => string[];
};
}
const);2. Autocomplete & compile-time typo detection
// TypeScript knows every sub-key under appTags.todos
appTags.todos.list() // OK: returns ["todos"]
appTags.todos.summary() // OK: returns ["todos", "summary"]
appTags.todos.byId(7) // OK: returns ["todo", 7]
appTags.todos.TYPO()
// ^^^^^
// TS2339: Property 'TYPO' does not exist on type
// '{ list: ...; summary: ...; byId: ... }'.
// — typos are caught at compile time, not runtime.3. meta.tags — typed end-to-end
import { useQuery } from "@tanstack/react-query";
const { data } = useQuery({
queryKey: ["todos"],
queryFn: fetchTodos,
meta: {
tags: [appTags.todos.list()],
// ^^^^^^^^^^^^^^^^^^^
// Fully typed: only valid tag arrays accepted.
// The value is ["todos"] at runtime — a plain
// array with a hidden TAG_PATH symbol attached.
},
});4. Server-side invalidateTags() — same types
import { invalidateTags } from "@tanstack-tools/query-tags";
import { appTags } from "#/lib/tag-contract";
// Server function — runs in Node, not the browser
export const addTodoServerFn = createServerFn({ method: "POST" })
.handler(async ({ data }) => {
await createTodo(data.name);
await tagInvalidation.invalidateTags({
tags: [appTags.todos()],
// ^^^^^^^^^^^^^^^^
// Parent tag: invalidates todos.list AND
// todos.summary AND todos.byId — all children.
// Type-checked: wrong tag = compile error.
scope: invalidationScope,
});
});5. One shared contract, two environments
// tag-contract.ts is imported by BOTH client and server.
// The same tag tree definition is the single source of truth.
//
// Client: useQuery({ meta: { tags: [appTags.todos.list()] } })
// Server: invalidateTags({ tags: [appTags.todos()] })
//
// If you rename a tag leaf you get a type error in both places.
// No magic strings. No mis-matched arrays.