Skip to content
qt

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
View Source
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.
@paramfactories - Nested object of tag factory functions.@returnsA fully-typed tag tree with autocomplete and compile-time safety.@example```ts const appTags = defineTags({ todos: { list: () => ["todos"], byId: (id: string) => ["todo", id] } }); appTags.todos.list(); // leaf tag value appTags.todos(); // TagGroup expanding to all todo leaves ```
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.
@paramfactories - Nested object of tag factory functions.@returnsA fully-typed tag tree with autocomplete and compile-time safety.@example```ts const appTags = defineTags({ todos: { list: () => ["todos"], byId: (id: string) => ["todo", id] } }); appTags.todos.list(); // leaf tag value appTags.todos(); // TagGroup expanding to all todo leaves ```
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.