structured-queries
v2.0.0
Published
Type-safe, hierarchical query options factories for TanStack Query — define and organise your queries as a structured, composable tree.
Downloads
415
Maintainers
Readme
Why?
Inspired by query-key-factory by Luke Morales. structured-queries takes the idea further with hierarchical sub-queries, parameterised nodes, infinite query support, and a single tree that produces ready-to-use query options.
Features
- Hierarchical query keys — built automatically from the tree structure
- Parameterised nodes — closure-based
queryFnwith type-safe parameters - Deep nesting — arbitrarily nested sub-queries via
$sub - Infinite queries — first-class
useInfiniteQuery/fetchInfiniteQuerysupport - Partial keys — uncalled dynamic nodes expose
.queryKeyfor invalidation - Type-safe cache —
DataTag-branded keys for typedgetQueryData skipTokensupport — conditional queries with full type narrowinginferQueryKeys— extract the union of all possible key tuples- Options passthrough —
staleTime,gcTime,retry, and all other TanStack Query options - Zero runtime dependencies — only
@tanstack/query-core >=5as a peer dep - ESM + CJS — dual output, tree-shakeable
Install
npm install structured-queriesPeer dependency:
@tanstack/query-core >=5.0.0, satisfied by any TanStack Query v5 package (@tanstack/react-query,@tanstack/vue-query, etc.).
Quick Start
import { createStructuredQuery } from 'structured-queries'
import { useQuery } from '@tanstack/react-query'
const todos = createStructuredQuery('todos', {
all: {
queryFn: () => fetch('/api/todos').then((r) => r.json()),
},
byId: (id: string) => ({
params: [id],
queryFn: () => fetch(`/api/todos/${id}`).then((r) => r.json()),
}),
})
// Fetch all todos
const { data } = useQuery(todos.all)
// Fetch a single todo
const { data: todo } = useQuery(todos.byId('abc'))
// Invalidate everything under "todos"
queryClient.invalidateQueries({ queryKey: todos.queryKey })
// Invalidate all "byId" queries regardless of param
queryClient.invalidateQueries({ queryKey: todos.byId.queryKey })API Reference
createStructuredQuery(scope, definition)
Creates a structured query tree for a single domain scope.
const tags = createStructuredQuery('tags', {
// Static leaf — queryFn required
all: {
queryFn: () => api.getTags(),
staleTime: 60_000,
},
// Dynamic (parameterised) node — function returning params + queryFn
byId: (id: string) => ({
params: [id],
queryFn: () => api.getTag(id),
subQueries: {
posts: {
queryFn: () => api.getTagPosts(id),
},
},
}),
// Scope node — groups children, optionally has its own queryFn
filters: {
subQueries: {
active: {
queryFn: () => api.getActiveTags(),
},
},
},
})Resolved query keys:
| Access | queryKey |
| --------------------------- | ---------------------------------------------- |
| tags | ["tags"] |
| tags.all | ["tags", "all"] |
| tags.byId | ["tags", "byId"] (partial, for invalidation) |
| tags.byId("1") | ["tags", "byId", "1"] |
| tags.byId("1").$sub.posts | ["tags", "byId", "1", "posts"] |
| tags.filters | ["tags", "filters"] |
| tags.filters.$sub.active | ["tags", "filters", "active"] |
Every node with a queryFn is directly compatible with useQuery, fetchQuery, etc.
Combining Multiple Domains
Use plain objects to combine multiple query trees into a single namespace:
import { createStructuredQuery } from 'structured-queries'
const tags = createStructuredQuery('tags', {
/* ... */
})
const news = createStructuredQuery('news', {
/* ... */
})
const users = createStructuredQuery('users', {
/* ... */
})
const api = { tags, news, users }
api.tags.all // { queryKey: ["tags", "all"], queryFn: ... }
api.news.latest // { queryKey: ["news", "latest"], queryFn: ... }
api.users.me // { queryKey: ["users", "me"], queryFn: ... }inferQueryKeys<T>
Type helper that extracts the union of all possible query key tuples from a tree.
import type { inferQueryKeys } from 'structured-queries'
type TagKeys = inferQueryKeys<typeof tags>
// readonly ["tags"]
// | readonly ["tags", "all"]
// | readonly ["tags", "byId"]
// | readonly ["tags", "byId", string]
// | readonly ["tags", "byId", string, "posts"]
// | readonly ["tags", "filters"]
// | readonly ["tags", "filters", "active"]Guide
Node Types
{
queryFn: () => fetch('/api/items').then(r => r.json()),
staleTime: 30_000,
gcTime: 300_000,
};(id: string) => ({
params: [id],
queryFn: () => fetch(`/api/items/${id}`).then((r) => r.json()),
})Multi-segment keys are supported:
;(p: { owner: string; name: string }) => ({
params: [p.owner, p.name],
queryFn: () => fetch(`/api/repos/${p.owner}/${p.name}`).then((r) => r.json()),
}){
queryFn: () => fetch('/api/items/summary').then(r => r.json()), // optional
subQueries: {
active: {
queryFn: () => fetch('/api/items?status=active').then(r => r.json()),
},
},
}Works on both static and dynamic nodes. Directly compatible with useInfiniteQuery / fetchInfiniteQuery.
const pages = createStructuredQuery('pages', {
// Static infinite query
list: {
queryFn: ({ pageParam }) => fetch(`/api/pages?cursor=${pageParam}`).then((r) => r.json()),
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor,
},
// Dynamic infinite query
search: (term: string) => ({
params: [term],
queryFn: ({ pageParam }) =>
fetch(`/api/search?q=${term}&cursor=${pageParam}`).then((r) => r.json()),
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor,
}),
})
const { data } = useInfiniteQuery(pages.list)
const { data: searchData } = useInfiniteQuery(pages.search('hello'))getPreviousPageParam and maxPages are also supported.
Deep Nesting
Sub-queries can be nested to arbitrary depth — including parameterised nodes inside other parameterised nodes:
const org = createStructuredQuery('org', {
byId: (orgId: string) => ({
params: [orgId],
queryFn: () => api.getOrg(orgId),
subQueries: {
members: {
queryFn: () => api.getMembers(orgId),
subQueries: {
active: { queryFn: () => api.getActiveMembers(orgId) },
},
},
project: (projectId: number) => ({
params: [projectId],
queryFn: () => api.getProject(orgId, projectId),
subQueries: {
tasks: { queryFn: () => api.getTasks(orgId, projectId) },
issue: (issueId: string) => ({
params: [issueId],
queryFn: () => api.getIssue(orgId, projectId, issueId),
subQueries: {
comments: { queryFn: () => api.getComments(orgId, projectId, issueId) },
},
}),
},
}),
},
}),
})
// Chain through $sub at every level
const data = await queryClient.fetchQuery(
org.byId('acme').$sub.project(42).$sub.issue('ISS-1').$sub.comments,
)
// queryKey → ["org", "byId", "acme", "project", 42, "issue", "ISS-1", "comments"]
// Invalidate at any level — cascades to all children
queryClient.invalidateQueries({
queryKey: org.byId('acme').$sub.project(42).queryKey,
})The $sub Namespace
Children of a node are accessible via the $sub property. This keeps query options objects clean — when you pass a node to useQuery or fetchQuery, only standard TanStack Query options are present as top-level properties.
// ✅ useQuery receives { queryKey, queryFn, staleTime } — no child properties mixed in
useQuery(todos.byId('123'))
// Access children explicitly via $sub
const comments = todos.byId('123').$sub.comments$sub is an enumerable property, so children are visible in IDE autocomplete and included in Object.keys() and spread operations. Nodes without subQueries have no $sub property.
skipToken Support
structured-queries supports TanStack Query's skipToken for conditional queries. When skipToken is used as the queryFn, the resolved type correctly includes SkipToken in the union, preventing accidental calls:
import { skipToken } from '@tanstack/react-query'
import { createStructuredQuery } from 'structured-queries'
const todos = createStructuredQuery('todos', {
byId: (id: string | undefined) => ({
params: [id ?? 'none'],
queryFn: id ? () => fetch(`/api/todos/${id}`).then((r) => r.json()) : skipToken,
}),
})
// useQuery handles skipToken natively — the query is disabled when id is undefined
const { data } = useQuery(todos.byId(undefined))Note: Nodes with
skipTokenin theirqueryFnare not compatible withuseSuspenseQuery, which requires a realqueryFn. Use theenabledoption instead if you need suspense support.
Type-Safe Cache Access
Query keys are branded with DataTag, so getQueryData returns the correct type without a manual generic:
await queryClient.fetchQuery(tags.all)
// data is inferred as string[] (from the queryFn return type)
const data = queryClient.getQueryData(tags.all.queryKey)For infinite queries the data type is automatically InfiniteData<TData, TPageParam>.
Query Options Passthrough
All standard TanStack Query options are supported on any node:
{
queryFn: () => api.getTags(),
staleTime: 60_000,
gcTime: 300_000,
retry: 3,
retryDelay: 1000,
networkMode: 'offlineFirst',
enabled: true,
refetchOnWindowFocus: false,
meta: { source: 'api' },
}TypeScript
Exported Types
| Type | Description |
| ------------------------ | ---------------------------------------------------------------------------- |
| QueryNode | Annotation helper for standard query nodes — use to type function parameters |
| InfiniteQueryNode | Annotation helper for infinite query nodes — use to type function parameters |
| DynamicQueryNode | Resolved dynamic node in the output tree (callable + .queryKey) |
| inferQueryKeys | Extracts the union of all possible query key tuples from a tree |
| QueryNodeOptions | Query options attachable to any node (everything except queryKey) |
| LeafDefinition | Static leaf node definition shape (input) |
| InfiniteLeafDefinition | Infinite query leaf definition shape (input) |
| ScopeDefinition | Scope node definition shape (input) |
| DynamicDefinition | Dynamic node definition shape (input) |
| NodeDefinition | Union of all node definition shapes (input) |
| StructuredQuery | Root output type of createStructuredQuery (advanced) |
| BuildTree | Recursive mapped type that builds the output tree (advanced) |
QueryNode and InfiniteQueryNode are structural interfaces for annotating your own functions and variables. They are not the actual return type of createStructuredQuery — the resolved type is the raw structural intersection so that hovering nodes shows the full shape immediately.
import type { QueryNode } from 'structured-queries'
// Annotate a function that accepts any standard query node
async function prefetchNode(queryClient: QueryClient, node: QueryNode<readonly string[], unknown>) {
await queryClient.prefetchQuery(node)
}
// Annotate a variable
const node: QueryNode<readonly ['todos', 'all'], Todo[]> = todos.allGotchas
Dynamic nodes create a new object on every call. Calling todos.byId(id) allocates a fresh object each time. TanStack Query uses hashQueryKey for query identity, so a new object per render won't cause extra fetches or duplicate subscriptions. For most apps this is fine. If you want a stable reference (e.g. for useEffect dependencies or passing to child components as props), memoize:
const opts = useMemo(() => todos.byId(id), [id])
const { data } = useQuery(opts)select at definition time doesn't affect the DataTag brand. Adding select to a node definition transforms the data at the hook level, but getQueryData(todos.all.queryKey) returns the raw (unselected) type. This matches how TanStack Query's own queryOptions() helper works.
Requirements
- TypeScript 5.4+
strict: truerecommended
