npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

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 queryFn with type-safe parameters
  • Deep nesting — arbitrarily nested sub-queries via $sub
  • Infinite queries — first-class useInfiniteQuery / fetchInfiniteQuery support
  • Partial keys — uncalled dynamic nodes expose .queryKey for invalidation
  • Type-safe cacheDataTag-branded keys for typed getQueryData
  • skipToken support — conditional queries with full type narrowing
  • inferQueryKeys — extract the union of all possible key tuples
  • Options passthroughstaleTime, gcTime, retry, and all other TanStack Query options
  • Zero runtime dependencies — only @tanstack/query-core >=5 as a peer dep
  • ESM + CJS — dual output, tree-shakeable

Install

npm install structured-queries

Peer 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 skipToken in their queryFn are not compatible with useSuspenseQuery, which requires a real queryFn. Use the enabled option 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.all

Gotchas

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: true recommended

License

MIT